diff --git a/backend/src/authenticate.ts b/backend/src/authenticate.ts index 676643c..4b50e87 100644 --- a/backend/src/authenticate.ts +++ b/backend/src/authenticate.ts @@ -12,6 +12,15 @@ const authenticate: (req: Request, res: Response, next: NextFunction) => Respons if (err || !decoded) { return res.status(401).send("401 Unauthorized: Token expired or invalid"); } + if (typeof decoded === "string") { + return res.status(401).send("401 Unauthorized: Token payload is invalid"); + } + const uuid = (decoded as { uuid?: string }).uuid; + if (!uuid) { + return res.status(401).send("401 Unauthorized: Token payload is missing uuid"); + } + // 将 token 中的用户身份暴露给后续路由使用(如删除当前用户) + res.locals.uuid = uuid; return next(); }); }; diff --git a/backend/src/file.ts b/backend/src/file.ts index 7e77582..7a60ec4 100644 --- a/backend/src/file.ts +++ b/backend/src/file.ts @@ -74,4 +74,35 @@ router.get("/download", authenticate, (req, res) => { } }); +// “痕迹抹除”:删除指定房间中的某个文件 +router.post("/delete", authenticate, (req, res) => { + const room = req.body?.room; + const filename = req.body?.filename; + if (!room || typeof room !== "string" || !filename || typeof filename !== "string") { + return res.status(422).send("422 Unprocessable Entity: Missing room or filename"); + } + const baseDirResolved = path.resolve(baseDir); + const roomDir = path.resolve(baseDirResolved, room); + const target = path.resolve(roomDir, filename); + try { + // 防止路径穿越:room 和 filename 解析后必须仍落在上传根目录之内 + const roomRel = path.relative(baseDirResolved, roomDir); + const fileRel = path.relative(roomDir, target); + if (roomRel.startsWith("..") || fileRel.startsWith("..")) { + return res.status(400).send("400 Bad Request: room or filename escapes the upload directory"); + } + if (!fs.existsSync(target)) { + return res.status(404).send("404 Not Found: File does not exist"); + } + if (!fs.statSync(target).isFile()) { + return res.status(400).send("400 Bad Request: target is not a file"); + } + fs.unlinkSync(target); + return res.send("File deleted successfully"); + } catch (err) { + console.error(err); + return res.sendStatus(500); + } +}); + export default router; diff --git a/backend/src/graphql.ts b/backend/src/graphql.ts index 8e65cb6..6bf8c56 100644 --- a/backend/src/graphql.ts +++ b/backend/src/graphql.ts @@ -1554,6 +1554,12 @@ export type GetUsersByUsernameQueryVariables = Exact<{ export type GetUsersByUsernameQuery = { __typename?: 'query_root', user: Array<{ __typename?: 'user', uuid: any, password: string }> }; +export type DeleteUserByUuidMutationVariables = Exact<{ + uuid: Scalars['uuid']['input']; +}>; + + +export type DeleteUserByUuidMutation = { __typename?: 'mutation_root', delete_user_by_pk?: { __typename?: 'user', uuid: any } | null }; export const AddMessageDocument = gql` mutation addMessage($user_uuid: uuid!, $room_uuid: uuid!, $content: String!) { @@ -1627,6 +1633,13 @@ export const GetUsersByUsernameDocument = gql` } } `; +export const DeleteUserByUuidDocument = gql` + mutation deleteUserByUuid($uuid: uuid!) { + delete_user_by_pk(uuid: $uuid) { + uuid + } +} + `; export type SdkFunctionWrapper = (action: (requestHeaders?:Record) => Promise, operationName: string, operationType?: string, variables?: any) => Promise; @@ -1658,6 +1671,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = }, getUsersByUsername(variables: GetUsersByUsernameQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise { return withWrapper((wrappedRequestHeaders) => client.request(GetUsersByUsernameDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'getUsersByUsername', 'query', variables); + }, + deleteUserByUuid(variables: DeleteUserByUuidMutationVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise { + return withWrapper((wrappedRequestHeaders) => client.request(DeleteUserByUuidDocument, variables, {...requestHeaders, ...wrappedRequestHeaders}), 'deleteUserByUuid', 'mutation', variables); } }; } diff --git a/backend/src/user.ts b/backend/src/user.ts index a93bd91..27f942f 100644 --- a/backend/src/user.ts +++ b/backend/src/user.ts @@ -1,5 +1,6 @@ import express from "express"; import jwt from "jsonwebtoken"; +import authenticate from "./authenticate"; import { sdk as graphql } from "./index"; interface userJWTPayload { @@ -71,4 +72,23 @@ router.post("/register", async (req, res) => { } }); +// “痕迹抹除”:删除当前登录用户及其所有记录 +// (user_room / message / note 等通过数据库外键 ON DELETE CASCADE 一并删除) +router.get("/delete", authenticate, async (req, res) => { + const uuid = res.locals.uuid as string | undefined; + if (!uuid) { + return res.status(401).send("401 Unauthorized: Missing or invalid token payload"); + } + try { + const mutationResult = await graphql.deleteUserByUuid({ uuid: uuid }); + if (!mutationResult.delete_user_by_pk) { + return res.status(404).send("404 Not Found: User does not exist"); + } + return res.send("User and all related records deleted successfully"); + } catch (err) { + console.error(err); + return res.sendStatus(500); + } +}); + export default router; diff --git a/database/design.md b/database/design.md index 508dc80..c9446c4 100644 --- a/database/design.md +++ b/database/design.md @@ -85,3 +85,88 @@ _一般来说,一个实体对应一张表,多对多的关系也可对应一 | | created_at | timestamp | | | 注:由于使用的是 PostgreSQL,其`text`类型指长度可变的字符串,与其他数据库可能不同([PostgreSQL: Documentation: 16: Chapter 8. Data Types](https://www.postgresql.org/docs/current/datatype.html)) + +--- + +## 作业补充:会议便签纸(选项 C) + +> 需求:用户可以为每个会议创建一张便签纸,便签纸仅创建者本人可见。 + +### 1. 分析需求 + +在原有「用户注册、登录 / 会议创建、加入 / 会议中的聊天室」基础上新增: + +- 用户在某会议中拥有一张自己的便签纸,可在上面记录要点与待办 +- 同一用户在同一会议只有一张便签纸;不同用户的便签互相不可见 + +### 2.1 标识实体 + +- 用户(用户名、密码) +- 会议(名称、介绍、邀请码、创建时间) +- **便签(内容、创建时间、修改时间)** ← 新增 + +### 2.2 标识关系 + +- 用户加入会议(用户——会议) +- 用户在会议中发出消息(用户——会议——消息) +- **用户在某会议拥有便签纸(用户——会议——便签)** ← 新增 + - “每个会议一张”意味着约束 `(user_uuid, room_uuid)` 唯一 + +### 2.3 E-R 图 + +```mermaid +erDiagram + user{ + String username + String password + } + room{ + String name + String intro + String invite_code + Time created_at + } + message{ + String content + Time created_at + } + note{ + String content + Time created_at + Time updated_at + } + user }|--o{ room : join + user ||--o{ message : send + room ||--o{ message : contain + user ||--o{ note : own + room ||--o{ note : contain +``` + +### 3.1 新增表设计 + +| 表 | 字段 | 数据类型 | 主键 | 外键 | 约束 | +| ---- | ---------- | --------- | ---- | --------- | --------------------------------------------- | +| note | id | uuid | 是 | | 默认 `gen_random_uuid()` | +| | user_uuid | uuid | | user.uuid | 级联删除/更新;`(user_uuid, room_uuid)` 唯一 | +| | room_uuid | uuid | | room.uuid | 级联删除/更新 | +| | content | text | | | | +| | created_at | timestamp | | | 默认当前时间 | +| | updated_at | timestamp | | | 默认当前时间,更新时自动刷新 | + +### 4. 仅自己可见的两种保障 + +1. 数据库层:便签行包含 `user_uuid`(属主),查询与修改都带上属主条件; +2. API 层(Hasura):`user` 角色的 `insert/select/update/delete` 权限均绑定会话变量 + `X-Hasura-User-Id`,且 `insert` 使用 `set: { user_uuid: X-Hasura-User-Id }`, + 客户端无法伪造属主。配置文件见 + `database/hasura/metadata/databases/workshop/tables/public_note.yaml`。 + +### 5. 落地文件 + +| 文件 | 说明 | +| ---- | ---- | +| `database/sql/note.sql` | PostgreSQL 版建表 + 触发器 + 示例数据(Hasura/Neon 环境执行) | +| `database/sql/note_mysql.sql` | 同设计的 MySQL 9.x 版(本机实测通过) | +| `database/graphql/note.graphql` | 创建/查询/修改/删除便签的 GraphQL 操作 | +| `database/hasura/metadata/databases/workshop/tables/public_note.yaml` | note 表的 Hasura 元数据与权限 | + diff --git a/database/graphql/note.graphql b/database/graphql/note.graphql new file mode 100644 index 0000000..7fbaa88 --- /dev/null +++ b/database/graphql/note.graphql @@ -0,0 +1,53 @@ +# 便签纸由 Hasura 的 insert set 自动写入当前登录用户(见 public_note.yaml), +# 客户端无需也不能传 user_uuid,因此仅需要 room_uuid 与 content。 + +mutation createNote($room_uuid: uuid!, $content: String!) { + insert_note_one(object: {room_uuid: $room_uuid, content: $content}) { + id + room_uuid + content + created_at + updated_at + } +} + +query getMyNoteInRoom($room_uuid: uuid!) { + note(where: {room_uuid: {_eq: $room_uuid}}) { + id + content + created_at + updated_at + room { + uuid + name + } + } +} + +query getMyNotes { + note(order_by: {updated_at: desc}) { + id + room_uuid + content + created_at + updated_at + room { + uuid + name + } + } +} + +mutation updateNote($id: uuid!, $content: String!) { + update_note_by_pk(pk_columns: {id: $id}, _set: {content: $content}) { + id + content + updated_at + } +} + +mutation deleteNote($id: uuid!) { + delete_note_by_pk(id: $id) { + id + } +} diff --git a/database/graphql/user.graphql b/database/graphql/user.graphql index d7780cd..ced2602 100644 --- a/database/graphql/user.graphql +++ b/database/graphql/user.graphql @@ -4,6 +4,12 @@ mutation addUser($username: String!, $password: String!) { } } +mutation deleteUserByUuid($uuid: uuid!) { + delete_user_by_pk(uuid: $uuid) { + uuid + } +} + query getUsersByUsername($username: String!) { user(where: {username: {_eq: $username}}) { uuid diff --git a/database/hasura/metadata/databases/workshop/tables/public_note.yaml b/database/hasura/metadata/databases/workshop/tables/public_note.yaml new file mode 100644 index 0000000..b19866b --- /dev/null +++ b/database/hasura/metadata/databases/workshop/tables/public_note.yaml @@ -0,0 +1,53 @@ +table: + name: note + schema: public +object_relationships: + - name: room + using: + foreign_key_constraint_on: room_uuid + - name: user + using: + foreign_key_constraint_on: user_uuid +insert_permissions: + - role: user + permission: + check: {} + set: + user_uuid: X-Hasura-User-Id + columns: + - content + - room_uuid + comment: 便签仅能作为当前登录用户创建(user_uuid 由会话变量写入) +select_permissions: + - role: user + permission: + columns: + - content + - created_at + - updated_at + - id + - room_uuid + - user_uuid + filter: + user_uuid: + _eq: X-Hasura-User-Id + comment: 只能看到自己创建的便签(仅自己可见) +update_permissions: + - role: user + permission: + columns: + - content + filter: + user_uuid: + _eq: X-Hasura-User-Id + check: + user_uuid: + _eq: X-Hasura-User-Id + comment: 只能修改自己便签的内容 +delete_permissions: + - role: user + permission: + filter: + user_uuid: + _eq: X-Hasura-User-Id + comment: 只能删除自己的便签 diff --git a/database/hasura/metadata/databases/workshop/tables/tables.yaml b/database/hasura/metadata/databases/workshop/tables/tables.yaml index c4eb8a7..921ee3a 100644 --- a/database/hasura/metadata/databases/workshop/tables/tables.yaml +++ b/database/hasura/metadata/databases/workshop/tables/tables.yaml @@ -1,4 +1,5 @@ - "!include public_message.yaml" +- "!include public_note.yaml" - "!include public_room.yaml" - "!include public_user.yaml" - "!include public_user_room.yaml" diff --git a/database/sql/note.sql b/database/sql/note.sql new file mode 100644 index 0000000..4c3cad9 --- /dev/null +++ b/database/sql/note.sql @@ -0,0 +1,38 @@ +-- PostgreSQL +-- 第11讲作业(选项 C:会议便签纸) +-- 需求:用户可以为每个会议创建一张便签纸,便签纸仅自己可见。 +-- * 每人每会议最多一张:由 UNIQUE(user_uuid, room_uuid) 保证 +-- * 仅创建者本人可见:由 Hasura 权限层(session 变量 X-Hasura-User-Id) +-- 控制,见 database/hasura/metadata/databases/workshop/tables/public_note.yaml +-- 本文件依赖 user.sql、room.sql 已先执行(外键引用 user、room)。 +create table if not exists public.note ( + id uuid default gen_random_uuid() not null, + user_uuid uuid not null, + room_uuid uuid not null, + content text not null, + created_at timestamp default current_timestamp not null, + updated_at timestamp default current_timestamp not null, + primary key (id), + unique (user_uuid, room_uuid) +); +alter table public.note +add constraint note_user_uuid_fkey foreign key (user_uuid) references public.user (uuid) on update cascade on delete cascade; +alter table public.note +add constraint note_room_uuid_fkey foreign key (room_uuid) references public.room (uuid) on update cascade on delete cascade; + +-- 触发器:修改便签内容时自动刷新 updated_at +create or replace function public.note_set_updated_at() returns trigger language plpgsql as $$ +begin + new.updated_at := current_timestamp; + return new; +end; +$$; +create trigger trg_note_set_updated_at before update on public.note +for each row execute function public.note_set_updated_at(); + +insert into public.note (user_uuid, room_uuid, content) values +('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-100000000001', '大家好,我是张三!这是咱们房间的便签纸。待办:今天 15:00 讨论暑培答辩分组。'), +('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-100000000001', '收到!我来准备演示用的投影和电脑。'), +('00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-100000000001', '(王五)我负责记录会议纪要~'), +('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-100000000002', '(张三在聊天室1的私人便签)别忘了下周还书。'), +('00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-100000000003', '(赵六)成语接龙战绩存档:心安理得→得过且过→过目不忘。'); diff --git a/database/sql/note_mysql.sql b/database/sql/note_mysql.sql new file mode 100644 index 0000000..ab7ad05 --- /dev/null +++ b/database/sql/note_mysql.sql @@ -0,0 +1,80 @@ +-- MySQL(9.7 实测可用)——第11讲作业(选项 C:会议便签纸)的 MySQL 版本 +-- 官方讲解环境为 PostgreSQL + Hasura,请优先使用同目录 user.sql、room.sql、note.sql; +-- 本文件把同一套设计翻译为 MySQL 语法,可在本机 MySQL 直接跑通并验证(已实测)。 +-- 注意:MySQL 9.x 已移除 md5(),故密码示例改用 sha2(),仅作占位演示。 + +SET NAMES utf8mb4; + +CREATE DATABASE IF NOT EXISTS web_workshop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +USE web_workshop; + +-- 1. user(同 user.sql) +CREATE TABLE IF NOT EXISTS `user` ( + uuid CHAR(36) NOT NULL, + username VARCHAR(64) NOT NULL, + password VARCHAR(64) NOT NULL, + PRIMARY KEY (uuid), + UNIQUE KEY uk_user_username (username) +) ENGINE = InnoDB; + +INSERT INTO `user` (uuid, username, password) VALUES + ('00000000-0000-0000-0000-000000000000', 'admin', sha2('123456', 256)), + ('00000000-0000-0000-0000-000000000001', '张三', sha2('张三', 256)), + ('00000000-0000-0000-0000-000000000002', '李四', sha2('李四', 256)), + ('00000000-0000-0000-0000-000000000003', '王五', sha2('王五', 256)), + ('00000000-0000-0000-0000-000000000004', '赵六', sha2('赵六', 256)); + +-- 2. room(同 room.sql) +CREATE TABLE IF NOT EXISTS room ( + uuid CHAR(36) NOT NULL, + name VARCHAR(64) NOT NULL, + intro VARCHAR(255) NOT NULL, + invite_code CHAR(6) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (uuid), + UNIQUE KEY uk_room_name (name), + UNIQUE KEY uk_room_invite_code (invite_code) +) ENGINE = InnoDB; + +INSERT INTO room (uuid, name, intro, invite_code) VALUES + ('00000000-0000-0000-0000-100000000001', '公共聊天室', '欢迎加入公共聊天室', 'gD9jE4'), + ('00000000-0000-0000-0000-100000000002', '聊天室1', '这是一个聊天室', 'oC3kY5'), + ('00000000-0000-0000-0000-100000000003', '聊天室2', '这是一个聊天室', 'uE8aY9'), + ('00000000-0000-0000-0000-100000000004', '聊天室3', '这是一个聊天室', 'aF2jR6'); + +-- 3. note:会议便签纸(本讲新增表) +-- * UNIQUE(user_uuid, room_uuid):每个用户在每个会议最多一张便签纸 +-- * 外键级联:用户或会议被删除时其便签随之删除 +-- * updated_at 在修改行时自动刷新(等价于 note.sql 中的触发器) +CREATE TABLE IF NOT EXISTS note ( + id CHAR(36) NOT NULL DEFAULT (UUID()), + user_uuid CHAR(36) NOT NULL, + room_uuid CHAR(36) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_note_user_room (user_uuid, room_uuid), + KEY idx_note_room (room_uuid), + CONSTRAINT fk_note_user FOREIGN KEY (user_uuid) REFERENCES `user` (uuid) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_note_room FOREIGN KEY (room_uuid) REFERENCES room (uuid) + ON UPDATE CASCADE ON DELETE CASCADE +) ENGINE = InnoDB; + +INSERT INTO note (user_uuid, room_uuid, content) VALUES + ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-100000000001', '大家好,我是张三!这是咱们房间的便签纸。待办:今天 15:00 讨论暑培答辩分组。'), + ('00000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-100000000001', '收到!我来准备演示用的投影和电脑。'), + ('00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-100000000001', '(王五)我负责记录会议纪要~'), + ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-100000000002', '(张三在聊天室1的私人便签)别忘了下周还书。'), + ('00000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-100000000003', '(赵六)成语接龙战绩存档:心安理得→得过且过→过目不忘。'); + +-- ================= 常见验证查询 ================= +-- 1) 某人某会议的便签(Hasura 中相当于 user_uuid 被 X-Hasura-User-Id 锁定) +-- SELECT r.name, u.username, n.content, n.updated_at +-- FROM note n JOIN room r ON n.room_uuid = r.uuid JOIN `user` u ON n.user_uuid = u.uuid +-- WHERE n.user_uuid = '00000000-0000-0000-0000-000000000001' +-- AND n.room_uuid = '00000000-0000-0000-0000-100000000001'; +-- 2) 同一个人在另一房间再插一张应报 ERROR 1062(唯一约束生效) +-- INSERT INTO note (user_uuid, room_uuid, content) VALUES +-- ('00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-100000000001', '重复便签,应失败'); diff --git a/frontend/public/about-me.css b/frontend/public/about-me.css new file mode 100644 index 0000000..ba2151c --- /dev/null +++ b/frontend/public/about-me.css @@ -0,0 +1,347 @@ +/* about-me.css —— 第9讲作业:关于我页面的样式 */ + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif; + color: #333; + background: linear-gradient(160deg, #a8edea 0%, #fed6e3 55%, #fbc2eb 100%); + background-attachment: fixed; +} + +.page { + width: min(920px, 92vw); + margin: 0 auto; + padding: 32px 8px 24px; +} + +/* 头部 */ +header { + text-align: center; + margin-bottom: 28px; +} + +header h1 { + font-size: 42px; + margin: 12px 0 6px; + color: #2d4c6b; + text-shadow: 0 2px 6px rgba(255, 255, 255, 0.6); + letter-spacing: 4px; +} + +.subtitle { + color: #556; + margin-top: 0; +} + +header nav a { + display: inline-block; + margin-top: 10px; + padding: 8px 20px; + border-radius: 999px; + background: rgba(45, 76, 107, 0.85); + color: #fff; + text-decoration: none; + transition: background 0.3s; +} + +header nav a:hover { + background: #1e3a54; +} + +/* 卡片网格布局 */ +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.card { + background: rgba(255, 255, 255, 0.72); + border-radius: 16px; + padding: 20px 24px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); + backdrop-filter: blur(10px); +} + +.card h2 { + margin-top: 4px; + margin-bottom: 12px; + font-size: 20px; + color: #2d4c6b; + border-bottom: 2px dashed #ffb6c1; + padding-bottom: 6px; +} + +.span-2 { + grid-column: 1 / -1; +} + +/* 个人简介 */ +.avatar { + width: 120px; + height: 120px; + border-radius: 50%; + object-fit: cover; + border: 4px solid #fff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + float: left; + margin: 0 18px 12px 0; +} + +blockquote { + clear: both; + margin: 14px 0 0; + padding: 10px 16px; + border-left: 4px solid #f093a7; + background: rgba(255, 255, 255, 0.5); + border-radius: 0 8px 8px 0; + color: #5a4a52; +} + +/* 表格 */ +table { + width: 100%; + border-collapse: collapse; + font-size: 15px; +} + +th, +td { + border: 1px solid #c9d4de; + padding: 8px 10px; + text-align: left; +} + +th { + background: #eef4fa; + width: 30%; + white-space: nowrap; +} + +a { + color: #2778c4; +} + +/* 列表 */ +ul, +ol { + line-height: 1.9em; + padding-left: 22px; + margin: 6px 0; +} + +/* 技能进度条 */ +meter { + width: 120px; + height: 16px; +} + +.skill-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.skill-name { + font-weight: 600; + flex: 1; +} + +/* 表单 */ +form label { + display: block; + margin: 10px 0 4px; + font-weight: 600; +} + +form input[type="text"], +form input[type="email"], +form textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid #b9c6d2; + border-radius: 8px; + font-family: inherit; + background: rgba(255, 255, 255, 0.9); +} + +form button { + margin-top: 12px; + padding: 8px 22px; + border: none; + border-radius: 999px; + background: #2d4c6b; + color: #fff; + font-size: 15px; + cursor: pointer; + transition: background 0.3s; +} + +form button:hover { + background: #1e3a54; +} + +/* 音乐播放器区域 */ +audio { + width: 100%; +} + +/* 页脚 */ +footer { + text-align: center; + color: #556; + margin-top: 24px; + font-size: 13px; +} + +/* ===== 第 10 讲作业新增样式 ===== */ + +.clock { + color: #5a6b7b; + margin: 4px 0 0; + font-size: 14px; + min-height: 1.2em; +} + +header nav button { + display: inline-block; + margin-top: 10px; + margin-left: 8px; + padding: 8px 20px; + border: none; + border-radius: 999px; + background: rgba(45, 76, 107, 0.85); + color: #fff; + cursor: pointer; + font: inherit; + transition: background 0.3s; +} + +header nav button:hover { + background: #1e3a54; +} + +#repo-list { + list-style: none; + padding: 0; + display: grid; + gap: 10px; + margin: 0 0 6px; +} + +#repo-list li { + border: 1px solid #dbe4ec; + border-radius: 10px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.55); +} + +#repo-list a { + font-weight: 600; + font-size: 16px; +} + +#repo-list .lang { + float: right; + color: #6b7680; + font-size: 13px; +} + +.muted { + color: #6b7680; + font-size: 13px; + margin: 4px 0 0; +} + +.tip { + min-height: 1.4em; + margin: 10px 0 0; + font-weight: 600; + color: #1e7d4f; +} + +/* 夜间模式 */ +body.dark { + background: linear-gradient(160deg, #141e30 0%, #243b55 60%, #2b5876 100%); + color: #dfe6ee; +} + +body.dark .card { + background: rgba(22, 32, 48, 0.82); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); +} + +body.dark header h1, +body.dark .card h2 { + color: #9fc2e8; +} + +body.dark .subtitle, +body.dark .clock, +body.dark .muted { + color: #9aa7b6; +} + +body.dark .card p, +body.dark .card li { + color: #d3dce6; +} + +body.dark th { + background: #1c2b3d; + color: #d3dce6; +} + +body.dark th, +body.dark td { + border-color: #3b4f66; +} + +body.dark blockquote { + background: rgba(255, 255, 255, 0.05); + color: #d3dce6; + border-left-color: #5b8db8; +} + +body.dark #repo-list li { + background: rgba(255, 255, 255, 0.05); + border-color: #3b4f66; +} + +body.dark form input[type="text"], +body.dark form input[type="email"], +body.dark form textarea { + background: #1c2b3d; + color: #e6ecf3; + border-color: #3b4f66; +} + +body.dark a { + color: #8ec5f0; +} + +body.dark .tip { + color: #7ad0a2; +} + +body.dark footer { + color: #8a98a8; +} + +@media (max-width: 700px) { + .grid { + grid-template-columns: 1fr; + } + + .avatar { + float: none; + display: block; + margin: 0 auto 12px; + } +} diff --git a/frontend/public/about-me.html b/frontend/public/about-me.html new file mode 100644 index 0000000..124ac6c --- /dev/null +++ b/frontend/public/about-me.html @@ -0,0 +1,143 @@ + + + + + + + 关于我 - 豆梓宁 + + + + +
+
+

关于我

+

豆梓宁 · 清华大学电子工程系 · 软件部暑培学员

+

正在加载时间…

+ +
+ +
+ +
+

个人简介

+ 清华之秋(占位头像) +

+ 你好!我是 豆梓宁,学号 2025010355。 + 目前正在参加电子系学生科协的暑期培训,从 Git、Linux、C#/.NET + 一路学到现在的 Web 开发,目标是亲手做出一个完整的前后端项目。 +

+

+ 这个页面是暑培 Web Workshop 第 9 讲(HTML & CSS)的作业: + 我尝试用最朴素的 HTML + CSS 画出一个属于自己的小天地。 +

+
「种一棵树最好的时间是十年前,其次是现在。」
+
+ + +
+

基本信息

+ + + + + + + + + + + + + + + + + + + + + +
姓名豆梓宁
学号2025010355
邮箱3094655597@qq.com
GitHubgithub.com/sililass
所在地北京 · 清华大学
+
+ + +
+

兴趣爱好

+
    +
  • 写代码:喜欢把一个想法从 0 到 1 变成真实可用的东西
  • +
  • 听音乐:最近在循环播放李健《风吹麦浪》
  • +
  • 摄影:喜欢拍校园的四季,尤其是清华的秋天
  • +
  • 桌游:是朋友局的"气氛组"常驻选手
  • +
+

最近的小目标

+
    +
  1. 按时完成暑培全部作业并真正理解每一讲
  2. +
  3. 和小伙伴一起完成一个完整的趣味会议小软件
  4. +
  5. 维护一个属于自己的个人网站
  6. +
+
+ + +
+

技能自评

+
+ C# / .NET(暑培学习中) + +
+
+ Git 版本管理 + +
+
+ Linux 基础 + +
+
+ HTML / CSS(学习ing) + +
+

数值会随着暑培进度不断上涨,敬请期待~

+
+ + +
+

给我留言

+
+ + + + + + + +
+

+
+ + +
+

循环中的一首歌

+ +
+ + +
+

我的 GitHub 仓库

+
    +
  • 正在从 GitHub 获取数据…
  • +
+

由 JavaScript 调用 api.github.com 实时获取(第 10 讲网络资源作业)。

+
+
+ +
© 2026 豆梓宁 · web-workshop 第 9 / 10 讲作业
+
+ + + diff --git a/frontend/public/about-me.js b/frontend/public/about-me.js new file mode 100644 index 0000000..c142e0a --- /dev/null +++ b/frontend/public/about-me.js @@ -0,0 +1,101 @@ +// about-me.js —— 第 10 讲(JS & TS)作业 +// 让"关于我"页面"动起来",包含:问候语 + 实时时钟、夜间模式、GitHub 仓库实时加载、表单提交反馈。 + +/* 1. 动态问候语 + 实时时钟(本地动态) */ +const getGreeting = () => { + const h = new Date().getHours(); + if (h < 6) return "夜深了,记得早点休息"; + if (h < 12) return "早上好"; + if (h < 14) return "中午好"; + if (h < 18) return "下午好"; + return "晚上好"; +}; + +const updateClock = () => { + const clockDOM = document.getElementById("clock"); + if (!clockDOM) return; + const now = new Date(); + const date = now.toLocaleDateString("zh-CN"); + const time = now.toLocaleTimeString("zh-CN"); + clockDOM.innerText = `${getGreeting()}!现在是 ${date} ${time}(北京时间)`; +}; + +updateClock(); +setInterval(updateClock, 1000); + +/* 2. 夜间 / 日间模式切换(使用 localStorage 记住偏好) */ +const themeToggleDOM = document.getElementById("theme-toggle"); +const applyTheme = (theme) => { + document.body.classList.toggle("dark", theme === "dark"); + if (themeToggleDOM) { + themeToggleDOM.textContent = theme === "dark" ? "☀️ 日间模式" : "🌙 夜间模式"; + } +}; + +let savedTheme = localStorage.getItem("about-me-theme"); +if (!savedTheme) { + savedTheme = + window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} +applyTheme(savedTheme); + +if (themeToggleDOM) { + themeToggleDOM.addEventListener("click", () => { + const nextTheme = document.body.classList.contains("dark") ? "light" : "dark"; + applyTheme(nextTheme); + localStorage.setItem("about-me-theme", nextTheme); + }); +} + +/* 3. 网络资源:实时加载 GitHub 公开仓库(fetch + Promise + async/await) */ +const GITHUB_USER = "sililass"; +const loadRepos = async () => { + const listDOM = document.getElementById("repo-list"); + if (!listDOM) return; + try { + const response = await fetch( + `https://api.github.com/users/${GITHUB_USER}/repos?sort=updated&per_page=5` + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const repos = await response.json(); + if (!Array.isArray(repos) || repos.length === 0) { + listDOM.innerHTML = "
  • 目前还没有公开仓库,期待他发奋图强~
  • "; + return; + } + listDOM.innerHTML = repos + .map( + (repo) => + `
  • + ${repo.name} + ${repo.language || "其他"} · ⭐ ${repo.stargazers_count}
    + ${repo.description || "暂无简介"} +
  • ` + ) + .join(""); + } catch (err) { + console.error(err); + listDOM.innerHTML = "
  • GitHub 数据加载失败,请检查网络后刷新重试。
  • "; + } +}; + +loadRepos(); + +/* 4. 留言表单提交反馈(DOM 事件) */ +const contactFormDOM = document.querySelector("#contact form"); +if (contactFormDOM) { + contactFormDOM.addEventListener("submit", (event) => { + event.preventDefault(); + const nicknameInput = document.getElementById("nickname"); + const tipDOM = document.getElementById("form-tip"); + const nickname = nicknameInput ? nicknameInput.value || "同学" : "同学"; + const submitTime = new Date().toLocaleTimeString("zh-CN"); + if (tipDOM) { + tipDOM.textContent = `🎉 感谢 ${nickname} 的留言(${submitTime})!这是静态示例,未来接入后端后就能真正送达啦。`; + } + contactFormDOM.reset(); + }); +} diff --git a/frontend/public/index.html b/frontend/public/index.html index 2f21068..1a204ea 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -28,6 +28,8 @@

    这是一个趣味会议软件


    进入主页      + 关于我 +      关于这个工程

    当前时间加载中... ...

    diff --git a/frontend/src/MainPanel.tsx b/frontend/src/MainPanel.tsx index fa05e44..88fcc54 100644 --- a/frontend/src/MainPanel.tsx +++ b/frontend/src/MainPanel.tsx @@ -17,6 +17,7 @@ interface MainPanelProps { refetchRooms: () => void; addChatBox: (id: number) => void; addFileShare: (id: number) => void; + addNote: (id: number) => void; } const MainPanel: React.FC = (props) => { @@ -183,6 +184,7 @@ const RoomList: React.FC = ({ refetchRooms, addChatBox, addFileShare, + addNote, }) => { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -248,6 +250,7 @@ const RoomList: React.FC = ({ room={item.room} handleOpenChat={() => addChatBox(index)} handleOpenFileShare={() => addFileShare(index)} + handleOpenNote={() => addNote(index)} /> )} /> @@ -287,12 +290,14 @@ interface RoomListItemProps { room: graphql.GetJoinedRoomsQuery["user_room"][0]["room"]; handleOpenChat: () => void; handleOpenFileShare: () => void; + handleOpenNote: () => void; } const RoomListItem: React.FC = ({ room, handleOpenChat, handleOpenFileShare, + handleOpenNote, }) => { const dateUTC = new Date(room.created_at); const date = new Date( @@ -332,6 +337,9 @@ const RoomListItem: React.FC = ({ 打开聊天室 + + 打开便签纸 + 打开文件共享空间 diff --git a/frontend/src/NotePanel.tsx b/frontend/src/NotePanel.tsx new file mode 100644 index 0000000..8636ab7 --- /dev/null +++ b/frontend/src/NotePanel.tsx @@ -0,0 +1,272 @@ +import { useEffect, useRef, useState } from "react"; +import { Button, Input, message, Spin } from "antd"; +import { gql, useMutation, useQuery } from "@apollo/client"; +import { user } from "./getUser"; +import * as graphql from "./graphql"; +import { Card, Container, Text } from "./Components"; + +// 便签纸(第11讲 C 的数据库设计 + 第13讲前端界面) +// 每个用户在某个会议只有一张便签,仅创建者本人可见(Hasura 权限由 +// X-Hasura-User-Id 会话变量保证,本组件无需也不能指定 user_uuid)。 +const GET_MY_NOTE = gql` + query getMyNoteInRoom($room_uuid: uuid!) { + note(where: { room_uuid: { _eq: $room_uuid } }) { + id + content + created_at + updated_at + } + } +`; + +const CREATE_NOTE = gql` + mutation createNote($room_uuid: uuid!, $content: String!) { + insert_note_one(object: { room_uuid: $room_uuid, content: $content }) { + id + content + updated_at + } + } +`; + +const UPDATE_NOTE = gql` + mutation updateNote($id: uuid!, $content: String!) { + update_note_by_pk(pk_columns: { id: $id }, _set: { content: $content }) { + id + content + updated_at + } + } +`; + +const AUTO_SAVE_DELAY_MS = 1000; + +type SaveStatus = "loading" | "empty" | "saving" | "saved" | "error"; + +interface NotePanelProps { + user: user | null; + room: graphql.GetJoinedRoomsQuery["user_room"][0]["room"] | undefined; + handleClose: () => void; +} + +interface NoteRow { + id: string; + content: string; + created_at: string; + updated_at: string; +} + +interface CreateNoteMutationResult { + insert_note_one?: { id: string; content: string; updated_at: string } | null; +} +interface UpdateNoteMutationResult { + update_note_by_pk?: { id: string; content: string; updated_at: string } | null; +} + +const formatTime = (value?: string) => { + if (!value) return ""; + const dateUTC = new Date(value); + if (Number.isNaN(dateUTC.getTime())) return ""; + const date = new Date(dateUTC.getTime() - dateUTC.getTimezoneOffset() * 60000); + return date.toLocaleString("zh-CN"); +}; + +const NotePanel: React.FC = ({ user, room, handleClose }) => { + const [text, setText] = useState(""); + const [noteId, setNoteId] = useState(null); + const [status, setStatus] = useState("loading"); + const [lastSavedAt, setLastSavedAt] = useState(""); + + const initialized = useRef(false); + const saveTimer = useRef(null); + const saveInFlight = useRef(false); + const textRef = useRef(""); + textRef.current = text; + const noteIdRef = useRef(null); + noteIdRef.current = noteId; + + const { data, loading: queryLoading, error: queryError } = useQuery< + { note: NoteRow[] }, + { room_uuid: string } + >(GET_MY_NOTE, { + skip: !room, + variables: { room_uuid: room?.uuid ?? "" }, + fetchPolicy: "network-only", + }); + + const [createNoteMutation] = useMutation< + CreateNoteMutationResult, + { room_uuid: string; content: string } + >(CREATE_NOTE); + const [updateNoteMutation] = useMutation< + UpdateNoteMutationResult, + { id: string; content: string } + >(UPDATE_NOTE); + + // 初次加载:从服务器读取自己在该会议的便签(若有) + const remoteNote = data?.note?.[0]; + useEffect(() => { + if (!queryLoading && !initialized.current) { + initialized.current = true; + if (remoteNote) { + setNoteId(remoteNote.id); + setText(remoteNote.content ?? ""); + setLastSavedAt(remoteNote.updated_at); + setStatus("saved"); + } else { + setStatus("empty"); + } + } + }, [queryLoading, remoteNote]); + + useEffect(() => { + if (queryError) { + console.error(queryError); + message.error("获取便签失败!"); + setStatus("error"); + } + }, [queryError]); + + // 卸载时清理未触发的自动保存定时器 + useEffect(() => { + return () => { + if (saveTimer.current) { + window.clearTimeout(saveTimer.current); + saveTimer.current = null; + } + }; + }, []); + + const doSave = async () => { + if (!room || saveInFlight.current) return; + const content = textRef.current; + const existingId = noteIdRef.current; + // 尚未创建便签且内容为空 -> 无需保存 + if (!existingId && content.trim() === "") { + setStatus("empty"); + return; + } + saveInFlight.current = true; + setStatus("saving"); + try { + if (existingId) { + const result = await updateNoteMutation({ + variables: { id: existingId, content }, + }); + if (!result.data?.update_note_by_pk) { + throw new Error("update returned empty result"); + } + setLastSavedAt(result.data.update_note_by_pk.updated_at); + } else { + const result = await createNoteMutation({ + variables: { room_uuid: room.uuid, content }, + }); + if (!result.data?.insert_note_one) { + throw new Error("create returned empty result"); + } + const newId = result.data.insert_note_one.id; + setNoteId(newId); + setLastSavedAt(result.data.insert_note_one.updated_at); + } + setStatus("saved"); + } catch (err) { + console.error(err); + message.error("保存便签失败!"); + setStatus("error"); + } finally { + saveInFlight.current = false; + } + }; + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setText(value); + if (saveTimer.current) { + window.clearTimeout(saveTimer.current); + } + // 已有便签(即使清空也要保存);或已有内容待创建 -> 进入“待保存” + const shouldSave = noteIdRef.current !== null || value.trim() !== ""; + setStatus(shouldSave ? "saving" : "empty"); + saveTimer.current = window.setTimeout(() => { + void doSave(); + }, AUTO_SAVE_DELAY_MS); + }; + + const handleBlur = () => { + // 失焦立即保存,避免关窗丢内容 + if (saveTimer.current) { + window.clearTimeout(saveTimer.current); + saveTimer.current = null; + } + void doSave(); + }; + + const Close = () => ( + + ); + + if (!user || !room) { + return null; + } + + const statusText: Record = { + loading: "", + empty: "还没有便签,输入内容后会自动创建(仅自己可见)", + saving: "正在自动保存…", + saved: `已自动保存于 ${formatTime(lastSavedAt)}`, + error: "保存失败,修改后会重试", + }; + + return ( + + + + + {room.name} + + + 便签纸 · 仅自己可见 + + + {queryLoading ? ( + + + + ) : ( + <> +
    + +
    + + + {queryLoading ? "" : statusText[status]} + + + + )} +
    + ); +}; + +export default NotePanel; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 3abb96e..aae00c0 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -16,6 +16,7 @@ const MainPanel = React.lazy(() => import("./MainPanel")); const LoginPage = React.lazy(() => import("./LoginPage")); const ChatBox = React.lazy(() => import("./ChatBox")); const FileShare = React.lazy(() => import("./FileShare")); +const NotePanel = React.lazy(() => import("./NotePanel")); axios.defaults.baseURL = process.env.REACT_APP_BACKEND_URL!; axios.interceptors.request.use((config) => { @@ -65,6 +66,7 @@ const App = () => { const user = getUser(); const [chatBoxList, setChatBoxList] = useState([]); const [fileShareList, setFileShareList] = useState([]); + const [noteList, setNoteList] = useState([]); const [currentDrag, setCurrentDrag] = useState(""); const draggableProps = { @@ -88,6 +90,14 @@ const App = () => { const removeFileShare = (idx: number) => { setFileShareList(fileShareList.filter((id) => id !== idx)); }; + const addNote = (idx: number) => { + if (!noteList.includes(idx)) { + setNoteList([...noteList, idx]); + } + }; + const removeNote = (idx: number) => { + setNoteList(noteList.filter((id) => id !== idx)); + }; const { data, error, refetch } = graphql.useGetJoinedRoomsQuery({ skip: !user, @@ -111,6 +121,7 @@ const App = () => { refetchRooms={refetch} addChatBox={addChatBox} addFileShare={addFileShare} + addNote={addNote} /> @@ -150,6 +161,22 @@ const App = () => { ))} + {noteList.map((idx) => ( + + + removeNote(idx)} + /> + + + ))} ); };