触发条件视图 — 账单页(BillPage)
页面:BillPage
路由:/bill
数据来源:全局 DataContext(useData()),数据在 HomePage 选择账单时 由 selectBill(billId) 触发预加载。BillPage 本身不直接调用 API(除了操作类触发)。
DataContext 数据挂载时机:
- 用户从 HomePage 点击某账单 →
selectBill(billId)→fetchBillDetails(billId)并行请求:GET /bills/:billId— 账单信息 + 成员列表 + 分类列表GET /transactions/:billId— 该账单所有交易记录
页面进入时
无直接 API 调用(数据由 DataContext 预加载)
数据流:
fetchBillDetails(billId)并行请求:GET /bills/:billId→ 返回bill(账单基本信息)+members(成员列表)+categories(分类列表)+myRole+myPermsGET /transactions/:billId→ 返回transactions[]+uploadMeta(最近一次 CSV 上传元信息)
- DataContext 将数据写入全局 state:
activeBill/members/transactions/categories/csvUploadMeta/myPerms
BillPage 读取的 Context 字段:
| 字段 | 来源 | 说明 |
|---|---|---|
bills |
DataContext | 用户所有账单列表 |
activeBill |
DataContext | 当前选中账单 |
transactions |
DataContext | 当前账单所有交易记录 |
members |
DataContext | 当前账单所有成员 |
csvUploadMeta |
DataContext | 最近一次 CSV 上传元信息 |
myPerms |
DataContext | 当前用户角色和权限 { role, can_edit, can_delete } |
loading |
DataContext | 全局加载状态 |
点击「账单选择器」切换账单时
前端操作:点击账单下拉选择器 → 选择另一个账单
触发 API(通过 selectBill(billId)):
1. GET /bills/:billId
说明:获取新选中账单的详情
请求:GET /api/bills/:id(billId 从选择器传递)
后端处理:
- 校验账单存在且用户是成员
- 返回
bill+members+categories+myRole+myPerms响应字段:
{
"bill": { "id", "name", "description", "owner_id", "created_at", "updated_at" },
"members": [{ "user_id", "role", "joined_at", "nickname", "phone", "avatar_url" }],
"categories": [{ "id", "name", "icon", "color", "type" }],
"myRole": "owner | editor | viewer",
"myPerms": { "can_edit": 1, "can_delete": 0 }
}
关联 DB 表:bills, bill_members, users, categories
2. GET /transactions/:billId
说明:获取新账单的全部交易记录
请求:GET /api/transactions/:billId
后端处理:
- 校验用户是账单成员
- 查询
transactions WHERE bill_id = :billId,按tx_time ASC升序排列 - 查询最近一次 CSV 上传元信息(JOIN
bill_csv_uploads+users) 响应字段:
{
"transactions": [{ "id", "bill_id", "tx_time", "category", "amount", "tx_type", ... }],
"uploadMeta": { "file_name", "uploaded_at", "uploaded_by_nickname", "uploaded_by_phone" } | null
}
关联 DB 表:transactions, bill_csv_uploads, users
点击「新建账单」按钮(输入框 + 提交)
前端操作:输入账单名称 → 按回车或点击「新建」按钮
触发 API:
POST /bills
请求:
{ "name": "账单名称", "description": "" }
约束:
nametrim 后非空- 不能与现有账单重名(前端已做预防性检查)
- 游客用户(
is_guest)不可创建
后端处理:
- UUID 生成 billId(10次碰撞检测)
- 事务:插入
bills→ 插入bill_members(角色=owner)→ 插入7个默认分类 - 返回新建账单完整对象
成功响应:
{ "bill": { "id": "uuid", "name": "...", "owner_id": "...", "created_at": "..." } }
DB 表:bills, bill_members, categories
后续动作:创建成功后自动调用 selectBill(res.data.bill.id) 选中新账单(触发上文「切换账单时」的 API 流程)
点击「铅笔图标」→ 修改账单名称
前端操作:点击重命名按钮 → 弹出编辑弹窗 → 输入新名称 → 点击「保存修改」
触发 API:
PUT /bills/:id
请求:
{ "name": "新账单名称", "description": "当前账单描述" }
权限:需 owner 或 editor 角色
后端处理:更新 bills SET name = :name, updated_at = NOW() WHERE id = :id
DB 表:bills
后续动作:成功后调用 fetchBills() + selectBill(billId) 刷新账单列表和详情
点击「垃圾桶图标」→ 删除账单
前端操作:点击删除按钮 → 弹出二次确认弹窗(需手动输入账单名称匹配)→ 点击「确认删除」
触发 API:
DELETE /bills/:id
权限:仅 owner
后端处理:
- 校验操作者是 owner
DELETE FROM bills WHERE id = :id(CASCADE 删除关联数据) DB 表:bills(联动删除bill_members、transactions、categories等)
后续动作:删除成功后:
- 调用
fetchBills()刷新列表 - 自动选中剩余的第一个账单(若无剩余账单,
activeBill = null显示空状态)
⚠️ 警告:这是覆盖式删除,所有历史交易数据、成员权限设置均不可恢复。
CSV 文件上传(拖拽或点击上传)
前端操作:选择 CSV 文件 → 自动触发上传(无需点击确认按钮)
触发 API:
POST /transactions/:billId/upload
Content-Type:multipart/form-data
请求体:
file: <CSV File>(最大 10MB)
权限:需 owner 或 editor(viewer 不可上传)
CSV 必填列:时间、分类、类型、金额
CSV 选填列:二级分类、ID、币种、账户1、账户2、备注、已报销、手续费、优惠券、记账者、账单标记、标签、账单图片、关联单笔
后端处理(关键行为):
- 校验文件存在(≤ 10MB)
- 校验角色权限
- PapaParse 解析 CSV
- 事务:
DELETE FROM transactions WHERE bill_id = :billId(全量覆盖,旧数据全部删除)- 批量 INSERT 新数据(每批 40 行)
- UPSERT
bill_csv_uploads(保留最新上传记录) - 更新
bills.updated_at
- 返回
{ success: true, count: 导入行数 }
DB 表:transactions(全量清空后重新插入)、bill_csv_uploads、bills
前端进度回调:
onStage('准备上传...')→onStage('正在上传文件...')→onStage('正在重新载入数据...')→onStage('上传完成')onProgress(0~95)跟随上传进度,上传完成后fetchBillDetails再刷新到 100%
格式错误处理:若后端返回 code: 'FORMAT_ERROR',前端弹出格式错误详情弹窗(缺失列 + 实际列名对照)
邀请成员(输入手机号 → 点击「邀请」)
前端操作:选择国际区号 → 输入手机号 → 点击「邀请」按钮
触发 API:
POST /bills/:id/invite
请求:
{ "phone": "+86138xxxxxxxx", "role": "viewer" }
权限:仅 owner 可邀请
后端处理:
- 校验操作者是 owner
- 校验手机号格式
- 在
users表查找该手机号(必须已注册,否则返回错误) - 校验被邀请人还不是账单成员
- 插入
bill_invitations(24小时有效期)
⚠️ 已知缺陷:
- 没有通知被邀请人的机制(邀请以"等待对方确认"的形式存在,但被邀请人完全无感知)
- 实际邀请流程不可用
DB 表:users, bill_members, bill_invitations
后续动作:成功后清空输入框 + 调用 selectBill(billId) 刷新成员列表
修改成员角色(点击角色下拉 → 选择新角色)
前端操作:owner 点击某成员的「角色」下拉 → 选择「编辑者」或「查看者」
触发 API:
PUT /bills/:billId/members/:memberId
请求:
{ "role": "editor" }
权限:仅 owner 可操作
约束:
- 不能修改
owner自己的角色(返回 400) - 目标成员必须存在
后端处理:
- 校验操作者是 owner
- 校验目标不是 owner
- 更新
bill_members SET role = :role, updated_at = NOW()DB 表:bill_members
后续动作:成功后调用 selectBill(billId) 刷新成员列表
移除成员(点击「移除」按钮 → 输入成员昵称确认)
前端操作:点击移除按钮 → 弹出二次确认弹窗 → 手动输入成员昵称匹配 → 点击「确认移除」
触发 API:
DELETE /bills/:billId/members/:memberId
权限规则:
owner可以移除任何人(除了自己)editor/viewer只能移除自己
约束:不能移除 owner(返回 400)
后端处理:
- 校验目标成员存在
- 校验不是 owner
- 校验操作者权限
DELETE FROM bill_members WHERE id = :memberId
DB 表:bill_members
后续动作:成功后调用 selectBill(billId) 刷新成员列表,显示成功提示
下载 CSV 模板(点击「下载模板」)
前端操作:点击「下载标准模板」按钮
触发:无 API 调用,纯前端生成 CSV 文件并触发浏览器下载
模板内容(硬编码示例行):
ID,时间,分类,二级分类,类型,金额,币种,账户1,账户2,备注,已报销,手续费,优惠券,记账者,账单标记,标签,账单图片,关联单笔
1,2026-03-01 12:00:00,餐饮,外卖,支出,25.5,CNY,微信支付,,午餐外卖,否,0,0,我,个人,,,
导入试用数据(点击「导入试用数据」)
前端操作:点击「导入试用数据」按钮
触发 API:与「CSV 文件上传」相同,走 uploadCSV(file)
差异:文件不是用户选择,而是前端 generateSampleCsvFile() 动态生成(从 2024-01-30 到"当前日期前4天"的模拟数据)
API 上传说明弹窗(点击「API 导出」)
前端操作:点击「API 导出」子卡片
触发:无 API 调用,纯前端弹窗显示 cURL 示例代码
弹窗内容:
- 当前账单 ID(
activeBill.id) - cURL 上传示例:
POST /api/transactions/:billId/upload - Token 刷新示例:
POST /api/auth/refresh - 说明滚动刷新机制
统计信息展示(无需 API)
页面头部统计卡:
- 成员数量:
members.length(来自 DataContext) - 交易记录数:
transactions.length(来自 DataContext) - 时间跨度:
useMemo计算transactions中tx_time的 min~max 范围
权限判断汇总
| 操作 | 所需角色 | API 端点 |
|---|---|---|
| 查看账单/成员/交易 | 任意成员(owner/editor/viewer) | GET /bills/:id, GET /transactions/:billId |
| 创建新账单 | 非游客(is_guest=0) | POST /bills |
| 修改账单名称 | owner / editor | PUT /bills/:id |
| 删除账单 | owner | DELETE /bills/:id |
| 上传 CSV | owner / editor(viewer 不可) | POST /transactions/:billId/upload |
| 邀请成员 | owner | POST /bills/:id/invite |
| 修改成员角色 | owner | PUT /bills/:id/members/:memberId |
| 移除成员 | owner(移除他人)/ 任意(移除自己) | DELETE /bills/:id/members/:memberId |