# 用户信息完善 & 一起记功能 实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 两个功能:① 用户可编辑昵称和上传头像,替换全局硬编码的"小菜";② 用户可创建/加入群组,在「我的」页切换身份,群组仅作为数据聚合视图。 **Architecture:** - 用户信息:扩展 `users` 表 → 服务端 multer 上传头像 → `/api/user` 路由 → 前端 `userStore` + 编辑页 - 一起记:新建 `groups`/`group_members` 表 → `transactions` 加 `group_id` 标签 → 「我的」页身份切换 → 群组仅做数据聚合 **Tech Stack:** Vue 3 Composition API (uni-app), Express + mysql2 + multer, SCSS Claymorphism --- ## 一、核心数据模型 ### 所有权 vs 聚合 ``` transactions 表: user_id = 谁记的(数据所有权,永远不变) group_id = 群组标签(仅用于聚合视图,可变) ``` **个人视图:** `WHERE user_id = 我` - 看到我的**所有记录**,不管 group_id 是什么 - 记录始终属于我 **群组视图:** `WHERE group_id = X AND user_id IN (当前成员)` - 只看到当前成员在该群组下记的记录 - 离开的成员的记录自动消失 **加入群组:** 新记的账可选择记到群组(group_id = X),仅自己可见的旧记录不动 **退出群组:** 该用户所有 `group_id = X` 的记录,`group_id` 设为 NULL - 记录回到个人视图 - 群组其他人看不到 **解散群组:** CASCADE 删除 groups → group_members 自动清理,transactions.group_id 设为 NULL ### 举例 ``` 用户 A 创建群组 G,用户 B 加入 A 在群组模式下记了午餐 ¥30: → { user_id: A, group_id: G, amount: 3000 } B 在群组模式下记了打车 ¥20: → { user_id: B, group_id: G, amount: 2000 } A 的个人视图:看到自己所有记录(包括这笔 ¥30) B 的个人视图:看到自己所有记录(包括这笔 ¥20) 群组 G 视图: A 和 B 都看到 ¥30 + ¥20 = ¥50 B 退出群组: → B 的记录 group_id 设为 NULL → B 的个人视图:仍然看到 ¥20(记录属于 B) → A 看群组 G:只看到 ¥30(B 的记录已不在) ``` --- ## 二、数据库设计 ### 现有表(不变) ``` users(id, openid, session_key, created_at, updated_at) categories(id, user_id, name, icon, color, type, sort_order, is_custom, created_at) transactions(id, user_id, amount, type, category_id, note, date, created_at, updated_at) budgets(id, user_id, amount, month, created_at, updated_at) ``` ### 变更 ``` users 表新增: nickname VARCHAR(50) DEFAULT '小菜' avatar_url VARCHAR(500) DEFAULT '' -- 服务端相对路径 新建 groups 表: id INT AUTO_INCREMENT PK name VARCHAR(100) NOT NULL invite_code VARCHAR(10) UNIQUE NOT NULL -- 6位邀请码 created_by INT NOT NULL FK→users.id created_at TIMESTAMP updated_at TIMESTAMP 新建 group_members 表: id INT AUTO_INCREMENT PK group_id INT NOT NULL FK→groups.id ON DELETE CASCADE user_id INT NOT NULL FK→users.id ON DELETE CASCADE role ENUM('owner','member') DEFAULT 'member' nickname VARCHAR(50) DEFAULT '' -- 群内昵称(预留) created_at TIMESTAMP UNIQUE KEY (group_id, user_id) transactions 表新增: group_id INT DEFAULT NULL -- 群组标签,NULL=纯个人记录 INDEX idx_group_date (group_id, date) -- 注意:不要 FK ON DELETE CASCADE,因为退出群组时需要保留记录 ``` --- ## 三、API 设计 ### 新增接口 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/user/me` | 获取当前用户信息 | | PUT | `/api/user/me` | 更新昵称 | | POST | `/api/user/avatar` | 上传头像 | | POST | `/api/groups` | 创建群组 | | GET | `/api/groups` | 获取用户的群组列表 | | GET | `/api/groups/:id` | 获取群组详情+成员 | | POST | `/api/groups/join` | 通过邀请码加入群组 | | POST | `/api/groups/:id/leave` | 退出群组(清除该用户的 group_id 标记) | | DELETE | `/api/groups/:id` | 解散群组(仅 owner) | ### 变更接口 **交易接口(`/api/transactions`):** | 参数 | 说明 | |------|------| | `group_id` 不传 | 个人视图:`WHERE user_id = ?`(所有记录) | | `group_id = 数字` | 群组视图:`WHERE group_id = ? AND user_id IN (当前成员)` | **统计接口(`/api/stats`):** 同上逻辑 **退出群组(`POST /api/groups/:id/leave`)关键逻辑:** ```sql -- 1. 清除该用户在该群组的记录标签 UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ? -- 2. 删除成员关系 DELETE FROM group_members WHERE group_id = ? AND user_id = ? ``` --- ## 四、前端架构 ### 新增文件 ``` client/src/ ├── api/user.ts -- 用户信息 API(含头像上传) ├── api/group.ts -- 群组 API ├── stores/user.ts -- 用户信息 Store ├── stores/group.ts -- 群组 Store(身份切换状态) ├── pages/profile-edit/index.vue -- 编辑资料页 └── pages/group-manage/index.vue -- 群组管理页(创建/加入/列表/退出/解散) ``` ### 修改文件 ``` server/src/ ├── db/schema.sql, db/init.ts -- 迁移 ├── index.ts -- 注册路由 + 静态文件 ├── routes/auth.ts -- 登录返回用户信息 ├── routes/user.ts -- [新建] ├── routes/group.ts -- [新建] ├── routes/transaction.ts -- 查询逻辑改造 └── routes/stats.ts -- 统计逻辑改造 client/src/ ├── api/auth.ts, api/transaction.ts, api/stats.ts -- 类型扩展 ├── stores/transaction.ts, stores/stats.ts -- 自动传 group_id ├── App.vue -- 初始化 ├── pages.json -- 注册页面 ├── pages/index/index.vue -- 显示真实昵称 └── pages/profile/index.vue -- 身份切换 + 头像/昵称 ``` ### 身份切换交互(「我的」页) ``` ┌─────────────────────────┐ │ profile-card (点击编辑) │ │ [头像] 昵称 > │ ├─────────────────────────┤ │ identity-card (点击切换) │ │ 👤 个人账本 仅自己 > │ ├─────────────────────────┤ │ quick-stats │ │ menu-card │ └─────────────────────────┘ 点击 identity-card → 底部弹出 ActionSheet: ┌─────────────────────────┐ │ 切换账本 │ │ ✓ 个人账本 │ │ 家庭账本 3人 │ │ 情侣账本 2人 │ │ ───────────────────── │ │ + 创建群组 │ │ + 加入群组 │ └─────────────────────────┘ ``` **创建群组流程:** 1. 点击「创建群组」→ 弹出输入框输入名称 → 确认 2. 创建成功 → 显示邀请码 → 自动复制到剪贴板 3. 群组自动出现在身份列表中 **加入群组流程:** 1. 点击「加入群组」→ 弹出输入框输入邀请码 → 确认 2. 加入成功 → 群组自动出现在身份列表中 **退出/解散(群组管理页):** - 点击群组卡片进入详情 → 显示成员列表、邀请码 - 成员可见「退出群组」按钮 - 群主可见「解散群组」按钮 --- ## 五、任务清单 ### Part 1: 用户信息完善(Task 1-7) #### Task 1: 数据库 — users 表扩展 **Files:** `server/src/db/schema.sql`, `server/src/db/init.ts` - [ ] **Step 1: 更新 schema.sql users 表** ```sql CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, openid VARCHAR(100) UNIQUE, session_key VARCHAR(100), nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称', avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` - [ ] **Step 2: init.ts runMigrations 添加迁移** ```typescript const hasNickname = await columnExists(conn, 'users', 'nickname') if (!hasNickname) { await conn.query("ALTER TABLE users ADD COLUMN nickname VARCHAR(50) DEFAULT '小菜'") } const hasAvatarUrl = await columnExists(conn, 'users', 'avatar_url') if (!hasAvatarUrl) { await conn.query("ALTER TABLE users ADD COLUMN avatar_url VARCHAR(500) DEFAULT ''") } ``` - [ ] **Step 3: 验证** `cd server && npm run db:init` - [ ] **Step 4: Commit** `feat: users 表添加 nickname、avatar_url` --- #### Task 2: 用户信息 API + 头像上传 **Files:** Create `server/src/routes/user.ts`, Modify `server/src/index.ts` - [ ] **Step 1: 安装 multer** `cd server && npm install multer && npm install -D @types/multer` - [ ] **Step 2: 创建 user 路由** 三个接口: - `GET /me` — 查询用户信息 - `PUT /me` — 更新昵称(验证非空、≤50字符) - `POST /avatar` — multer 接收 `file` 字段,存到 `uploads/avatars/`,更新 `users.avatar_url`,删除旧文件 - [ ] **Step 3: index.ts 注册路由 + 静态文件** ```typescript import userRoutes from './routes/user' import path from 'path' app.use('/uploads', express.static(path.join(__dirname, '../uploads'))) app.use('/api/user', apiLimiter, userRoutes) ``` - [ ] **Step 4: Commit** `feat: 用户信息 API + 头像上传` --- #### Task 3: 登录返回用户信息 **Files:** `server/src/routes/auth.ts`, `client/src/api/auth.ts`, `client/src/App.vue` - [ ] **Step 1: auth.ts 两个登录路由返回 `nickname`, `avatar_url`** - [ ] **Step 2: auth.ts LoginResult 类型扩展** - [ ] **Step 3: App.vue 登录后存储 `xc:nickname`, `xc:avatar_url`** - [ ] **Step 4: Commit** `feat: 登录返回用户信息` --- #### Task 4: 用户信息 Store **Files:** Create `client/src/api/user.ts`, Create `client/src/stores/user.ts` - [ ] **Step 1: api/user.ts** — `getUserInfo`, `updateNickname`, `uploadAvatar`(用 `uni.uploadFile`) - [ ] **Step 2: stores/user.ts** — `nickname`, `avatarUrl` computed,`fetchUserInfo`, `updateProfile`, `uploadAvatar` - [ ] **Step 3: Commit** `feat: 用户信息 Store` --- #### Task 5: 个人资料编辑页面 **Files:** Create `client/src/pages/profile-edit/index.vue`, Modify `client/src/pages.json` - [ ] **Step 1: pages.json 注册页面** - [ ] **Step 2: 创建编辑页** — 头像点击选择+上传,昵称输入框,保存按钮 - [ ] **Step 3: Commit** `feat: 个人资料编辑页面` --- #### Task 6: 「我的」页面显示真实用户信息 **Files:** `client/src/pages/profile/index.vue` - [ ] **Step 1: 引入 userStore,替换硬编码头像和昵称** - [ ] **Step 2: 点击 profile-card 跳转编辑页** - [ ] **Step 3: Commit** `feat: 我的页面显示真实头像和昵称` --- #### Task 7: 首页显示真实昵称 **Files:** `client/src/pages/index/index.vue` - [ ] **Step 1: 引入 userStore,替换 `小菜` 为 `userStore.nickname`** - [ ] **Step 2: Commit** `feat: 首页问候语显示真实昵称` --- ### Part 2: 一起记功能(Task 8-14) #### Task 8: 数据库 — 群组表 **Files:** `server/src/db/schema.sql`, `server/src/db/init.ts` - [ ] **Step 1: schema.sql 添加 groups, group_members 表,transactions 添加 group_id** 注意:`transactions.group_id` **不加外键约束**,因为退出群组时需要保留记录(group_id 设为 NULL)。 - [ ] **Step 2: init.ts runMigrations 添加群组迁移** ```typescript const hasGroupId = await columnExists(conn, 'transactions', 'group_id') if (!hasGroupId) { // 创建 groups 表 // 创建 group_members 表 // ALTER TABLE transactions ADD COLUMN group_id INT DEFAULT NULL // ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date) } ``` - [ ] **Step 3: 验证** `cd server && npm run db:init` - [ ] **Step 4: Commit** `feat: 群组表创建,transactions 添加 group_id` --- #### Task 9: 群组 API **Files:** Create `server/src/routes/group.ts`, Modify `server/src/index.ts` - [ ] **Step 1: 创建 group 路由** | 接口 | 关键逻辑 | |------|---------| | `POST /` | 生成邀请码,事务插入 groups + group_members(role=owner) | | `GET /` | JOIN group_members,含 member_count | | `GET /:id` | 验证成员身份,返回群组信息 + 成员列表(含 nickname, avatar_url) | | `POST /join` | 验证邀请码,检查非成员,插入 group_members(role=member) | | `POST /:id/leave` | **关键:先 `UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?`,再 `DELETE FROM group_members`** | | `DELETE /:id` | 仅 owner,`DELETE FROM groups`(CASCADE 清理 members,transactions.group_id 因无外键需手动清理) | **解散群组时手动清理 transactions:** ```sql UPDATE transactions SET group_id = NULL WHERE group_id = ? DELETE FROM groups WHERE id = ? ``` - [ ] **Step 2: index.ts 注册路由** - [ ] **Step 3: Commit** `feat: 群组 API` --- #### Task 10: 交易和统计 API 支持群组视图 **Files:** `server/src/routes/transaction.ts`, `server/src/routes/stats.ts` **核心改造:查询逻辑根据 `group_id` 参数切换视图模式。** - [ ] **Step 1: transaction.ts GET / 改造** ```typescript const { group_id } = req.query if (group_id && group_id !== 'null') { // 群组视图:群组下所有成员的记录 // 先验证当前用户是群组成员 const [memberCheck] = await pool.query( 'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?', [group_id, req.userId] ) if ((memberCheck as any[]).length === 0) { return res.status(403).json({ code: 40300, message: '无权访问此群组' }) } where = `WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)` params = [group_id, group_id] } else { // 个人视图:我的所有记录(不管 group_id) where = 'WHERE t.user_id = ?' params = [req.userId] } ``` - [ ] **Step 2: transaction.ts POST / 改造** 创建记录时接受可选 `group_id` 参数,写入数据库。 - [ ] **Step 3: transaction.ts PUT/DELETE 改造** 编辑/删除权限:只能操作自己 `user_id` 的记录(不管 group_id)。 - [ ] **Step 4: stats.ts 三个路由改造** 与 transaction 相同的视图切换逻辑。 - [ ] **Step 5: Commit** `feat: 交易和统计 API 支持群组视图` --- #### Task 11: 群组客户端 Store **Files:** Create `client/src/api/group.ts`, Create `client/src/stores/group.ts`, Modify `client/src/stores/transaction.ts`, Modify `client/src/stores/stats.ts` - [ ] **Step 1: api/group.ts** — 封装群组 API - [ ] **Step 2: stores/group.ts** ```typescript // 核心状态 const currentGroupId = ref(null) // null = 个人模式 const isGroupMode = computed(() => currentGroupId.value !== null) // 切换方法 function switchToPersonal() { currentGroupId.value = null uni.removeStorageSync('xc:currentGroupId') } function switchToGroup(id: number) { currentGroupId.value = id uni.setStorageSync('xc:currentGroupId', id) } ``` - [ ] **Step 3: 更新 transaction/stats Store** 每个 fetch 方法自动读取 `useGroupStore().currentGroupId` 并传给 API。 - [ ] **Step 4: Commit** `feat: 群组 Store + 数据层自动跟随` --- #### Task 12: 「我的」页身份切换 **Files:** `client/src/pages/profile/index.vue` - [ ] **Step 1: 在 profile-card 和 quick-stats 之间添加身份切换卡片** ```vue {{ groupStore.isGroupMode ? groupStore.currentGroup?.name : '个人账本' }} {{ groupStore.isGroupMode ? groupStore.currentGroup?.member_count + ' 人共享' : '仅自己可见' }} ``` - [ ] **Step 2: 底部弹出身份选择面板** ```vue 切换账本 个人账本 ... 创建群组 加入群组 ``` - [ ] **Step 3: 创建群组交互** 点击「创建群组」→ `uni.showModal` 输入名称 → 调用 `groupStore.createGroup()` → 成功后 `uni.setClipboardData` 复制邀请码 + Toast - [ ] **Step 4: 加入群组交互** 点击「加入群组」→ `uni.showModal` 输入邀请码 → 调用 `groupStore.joinGroup()` → 成功后 Toast - [ ] **Step 5: 切换后刷新数据** 切换身份后调用 `refreshData()` 重新加载当前页的 stats 和 transactions。 - [ ] **Step 6: Commit** `feat: 我的页身份切换` --- #### Task 13: 群组管理页面 **Files:** Create `client/src/pages/group-manage/index.vue`, Modify `client/src/pages.json` - [ ] **Step 1: pages.json 注册页面** - [ ] **Step 2: 创建群组管理页面** 页面功能: - 群组列表(卡片形式,显示名称、人数、邀请码) - 点击邀请码可复制 - 成员列表 - 「退出群组」按钮(普通成员)— 二次确认:"退出后你在该群组的记录将回到个人账本" - 「解散群组」按钮(群主)— 二次确认:"所有群组记录将被清除" - [ ] **Step 3: Commit** `feat: 群组管理页面` --- #### Task 14: App.vue 初始化 + 最终集成 **Files:** `client/src/App.vue`, `client/src/pages/index/index.vue` - [ ] **Step 1: App.vue 初始化 groupStore** 登录成功后 `groupStore.init()` + `groupStore.fetchGroups()` - [ ] **Step 2: 首页 fetchTodayData 传 group_id** ```typescript const groupStore = useGroupStore() const data = await getOverview({ startDate: today, endDate: today, group_id: groupStore.currentGroupId }) ``` - [ ] **Step 3: 验证完整流程** - [ ] **Step 4: Commit** `feat: 集成完成` --- ## 六、验证清单 ### 用户信息 - [ ] 「我的」页显示真实头像和昵称 - [ ] 点击头像区域跳转编辑页,可上传头像(存到服务端)、修改昵称 - [ ] 保存后首页/我的页同步更新 ### 一起记 — 创建/加入 - [ ] 身份切换面板中点击「创建群组」→ 输入名称 → 获得邀请码(自动复制) - [ ] 点击「加入群组」→ 输入邀请码 → 加入成功 - [ ] 身份列表中出现新群组 ### 一起记 — 记账归属 - [ ] 群组模式下记账 → 记录归属自己(user_id = 我),group_id = 群组 - [ ] 个人视图:看到自己所有记录(含群组中记的) - [ ] 群组视图:看到所有成员在该群组下记的记录 - [ ] 编辑/删除:只能操作自己记的记录 ### 一起记 — 退出/解散 - [ ] 退出群组 → 自己的记录回到个人视图,群组看不到 - [ ] 解散群组 → 群组消失,所有人的记录回到个人视图 - [ ] 退出/解散后自动切回个人模式