Compare commits

...

33 Commits

Author SHA1 Message Date
218c23687d fix(t04): improve add page tags admin and undo delete 2026-06-15 16:30:29 +08:00
b33ebf0879 feat(t03): improve performance and UX
- add category/tag caching and optimistic updates

- add stats dashboard aggregation and batched tracking

- add undo delete snackbar and group read-only view mode

- improve app-ready timeout handling and refresh guards
2026-06-11 16:26:46 +08:00
5a0ae0b28d refactor(iter-v2): T02 code quality - shared components, transactions, utils
- C2: ColorPicker and EditModal reusable components extracted
- C2: tag-manage and category-manage pages use EditModal (remove ~120 dup lines)
- C3: Upload logic extracted to server/src/utils/upload.ts
- C4: /recurring/sync wrapped in DB transaction
- C7: Backup download uses API_BASE (no hardcoded URLs)
- C9: getAvatarUrl() utility function, used in 5 files
- C1: getCurrentMonth() documented as aligned with server date.ts
2026-06-11 09:49:17 +08:00
d170df5c51 fix(iter-v2): T01 QA fixes - race condition, hook regex, deleted user check
- BUG-1: Pre-commit hook allows .env.example (grep -v filter)
- BUG-2: /auth/refresh uses transaction + FOR UPDATE to prevent race condition
- WARN-1: Auth middleware checks deleted_at with 5-min TTL cache
- WARN-2: PUT /admin/users/:id/status filters deleted_at IS NULL
- WARN-3: Install node-cron@^3.0.3
2026-06-11 09:34:27 +08:00
263a7c4616 feat(iter-v2): T01 complete - security + infrastructure
- S1: .githooks/pre-commit blocks .env commits, .env.test in .gitignore
- S3: TOKEN_SECRET centralized to config/token.ts, all files import from it
- S3: checkTokenSecret enforces min 32-char TOKEN_SECRET at startup
- S5: Refresh Token mechanism (Access 2h + Refresh 30d, dual token)
- S5: POST /auth/refresh endpoint with rotation and max 5 per user
- S5: Frontend request.ts auto-refresh on 40101, concurrent-safe
- S6: Soft delete (deleted_at column), admin soft-delete with confirmName
- S6: POST /admin/users/:id/restore endpoint
- S6: All user queries filter deleted_at IS NULL
- S7: DB connection requires env vars, no fallback credentials
- Infra: jest.config.js, vitest.config.ts, .env.test
- Infra: node-cron, jest, ts-jest, supertest, vitest, @pinia/testing
2026-06-11 09:24:10 +08:00
61f9b33f8c feat(iter-v2): T01 partial - TOKEN_SECRET check, SQL param, export credential, docs
- S2: logs.ts LIMIT/OFFSET SQL parameterization (? placeholders)
- S3: checkTokenSecret() startup validation, JWT_SECRET→TOKEN_SECRET in backup.ts
- S3: Access Token expiry shortened to 2h (from 30d)
- S4: Export download uses HMAC signed short-term token (/prepare endpoint)
- S4: Frontend export.ts adapted for prepare-then-download flow
- .env.example reorganized with security notes
- Added PRD and architecture docs for iteration v2
2026-06-11 08:58:24 +08:00
ae9b415822 refactor: TransactionItem 左滑提示改为 prop 控制 2026-06-10 17:35:58 +08:00
31f6487d61 feat: 前后端功能对齐 - 实现8个对齐差距
P0-1: 分类拖拽排序 - category-manage 添加拖拽 UI
P1-1: 反馈回复展示 - 新增 GET /feedback/mine + 前端我的反馈Tab
P1-2: 备份管理页面 - 新增下载端点 + backup-manage 页面
P2-1: 统计年度/周视图 - stats 支持 period 参数 + 前端维度切换器
P2-2: 数据导入 - 新增 POST /import 端点 + data-import 页面
P2-3: 数据导出服务端化 - 新增 GET /export 流式端点
P3-1: 健康检查展示 - health 移到 auth 前 + admin 状态卡片
P3-2~4: 交易标签系统 - tags CRUD + 交易关联 + 按标签筛选统计

后端: 新增 tag.ts/export.ts 路由, 改造 feedback/backup/transaction/stats
前端: 新增 DragSortList 组件, 3个新页面, 改造 7 个现有页面
QA 修复: 5个严重Bug + 4个潜在问题
2026-06-10 17:30:36 +08:00
5df910c9f1 feat(ui): UI/交互优化 — 动画系统+Design Token+骨架屏+微反馈
1. Design Token 统一:CategoryIcon/BudgetBar/Numpad/Skeleton/TransactionItem
   硬编码hex颜色替换为 $primary/$border/$danger 等SCSS变量
2. 入场动画:mixins.scss 添加 fade-in-up/bounce-in/count-up 等 mixin+keyframes,
   首页卡片/金额/快捷按钮应用交错入场动画
3. 记账成功动画增强:SaveSuccess 增加纸屑粒子爆发+光环脉冲效果,
   prefers-reduced-motion 下自动隐藏
4. 骨架屏替代文字加载:recurring/group-manage/notifications 三个页面
   从"加载中..."替换为骨架屏;空状态增加图标容器+引导文案
5. 交互微反馈:分类按压0.92缩放、推荐虚线呼吸动画、
   Tab滑块cubic-bezier优化、快捷按钮弹性按压
6. 左滑删除提示:TransactionItem首项1.5s后自动微微左滑再回弹,
   引导用户发现滑动删除功能
2026-06-10 15:59:42 +08:00
9f5802d634 fix: 修复3个高优先级Bug + 5个中低优先级问题
BUG-01: recurring /sync next_date 更新逻辑错误(应用循环后的 next)
BUG-02: bills onReachBottom loading 缺 .value
BUG-03: group store switchToPersonal 缺少 await (3处)
OPT-04: notification requireNotificationEditAuth 加 try-catch
OPT-05: category 迁移源分类条件改为 is_custom=1 AND user_id=?
OPT-06: user 旧头像删除加 try-catch + 日志
CODE-02: format.ts 跨年日期显示年份
CODE-05: notification isValidUrl 去掉相对路径允许
2026-06-10 15:59:12 +08:00
f586f9e117 fix: 金额编辑器小数位溢出挤位 + 编辑页自动聚焦 + 周期账单移除冗余聚焦 2026-06-09 16:54:13 +08:00
6b26951b1b fix: 金额输入框样式优化 + 预算页键盘遮挡修复 + admin 样式修复
- AmountEditor: 未聚焦时透明,聚焦时粘土卡片浮起;¥/单位 baseline 对齐
- budget: 补充 Numpad 占位高度,auto-focus 改为 false 缩短页面
- admin/feedback: 补充 modal-mask mixin
- admin/track: filter-tabs 与 scroll-view 分离 + 格式化
2026-06-09 14:44:25 +08:00
768c72d7b7 feat: 金额编辑器交互优化 — 支持光标定位、插入删除、长按删除
- 新增 AmountEditor 组件,页面只接一个组件即可获得完整金额编辑体验
- Numpad 增强:支持 cursorIndex 光标输入 + 长按连续删除
- 金额编辑纯函数 amount-edit.ts 集中管理插入/删除/校验规则
- 记一笔、预算页、周期账单弹窗统一接入 AmountEditor
- 默认聚焦金额、点击备注/日期/分类隐藏 Numpad、再次点击金额恢复
- 空值输入小数点规范为 0.
2026-06-09 14:09:14 +08:00
ac20a70a20 refactor: 移除前端接口缓存 — 每次 onShow 都实时请求最新数据 2026-06-09 09:21:16 +08:00
4e4b7dac69 fix: 周期账单弹窗 — Numpad 移到 scroll-view 外 + 防重复提交 + @confirm 绑定 2026-06-09 09:13:28 +08:00
df6f1a54d6 refactor: Numpad 组件支持 fixed prop — 弹窗内嵌 / 页面固定两种模式 2026-06-09 09:10:36 +08:00
7bf899f0b1 refactor: 周期账单 Numpad 改为弹窗内嵌,移除 fixed 定位 2026-06-09 09:08:03 +08:00
a277f1533b fix: 首页刷新/反馈按钮/周期键盘三个 bug 修复 2026-06-09 09:06:44 +08:00
f745f4b4f0 fix: 修复 feedback 状态类型 — string → Feedback['status'] 联合类型 2026-06-09 09:02:02 +08:00
6908ffed0e feat: 智能分类建议 — 根据金额/备注/历史自动推荐分类 2026-06-08 17:21:10 +08:00
057c69800e feat: 周期账单 — 自动生成定期交易(房租/订阅/工资) 2026-06-08 17:17:16 +08:00
986c187156 feat: 忽略反馈也支持回复 — 统一标记处理/忽略的回复流程 2026-06-08 17:03:50 +08:00
91ff95ea48 feat: 反馈处理增加回复功能 — 管理员可回复用户反馈并推送通知 2026-06-08 17:02:34 +08:00
a1e62e487d feat: 数据导出增强 — 日期范围/类型筛选/CSV+JSON 格式 2026-06-08 16:58:48 +08:00
be665f7859 fix: S3 UI优化 — 底部弹窗/FAB 安全区适配 2026-06-08 16:54:56 +08:00
4e43a9049a fix: S2 缺陷修复 — emoji/类型/双重请求/动画/prefers-reduced-motion 2026-06-08 16:53:27 +08:00
618cd20ebf docs: S1 全面扫描汇总报告 — 12 项发现 2026-06-08 16:47:34 +08:00
3279440ce6 docs: S1 全面扫描实施计划 2026-06-08 16:40:43 +08:00
2b04f057a9 docs: 质量迭代设计文档 — S1 全面扫描方案 2026-06-08 16:37:26 +08:00
41cddcd2c1 fix: 修复 feedback 查询 LIMIT/OFFSET 参数类型错误
- 和 logs.ts 同样的问题,execute 对参数类型要求严格
- 改为直接嵌入 SQL 字符串
2026-06-08 16:24:15 +08:00
4d3a390075 fix: queryTrackEvents 也过滤 undefined 值 2026-06-08 16:22:02 +08:00
ef4ab774b0 fix: 修复日志查询 undefined 序列化问题
- queryLogs 过滤掉 undefined 值
- 防止 undefined 被序列化为 'undefined' 字符串导致筛选失败
2026-06-08 16:14:22 +08:00
af15a41186 fix: 日志和埋点页面修复
日志页:
- 关键词搜索添加搜索按钮
- 修复搜索样式重复问题

埋点页:
- 事件类型显示中文名称(页面访问、用户操作等)
- 今日事件和热门事件使用标签样式
- 添加日期筛选和关键词搜索
- 事件类型筛选可横向滚动
2026-06-08 16:12:38 +08:00
110 changed files with 18983 additions and 903 deletions

10
.githooks/pre-commit Normal file
View File

@@ -0,0 +1,10 @@
#!/bin/sh
# Prevent .env files (except .env.example) from being committed
STAGED_ENV=$(git diff --cached --name-only | grep -E '\.env$|\.env\.' | grep -v '\.env\.example$')
if [ -n "$STAGED_ENV" ]; then
echo "❌ ERROR: Attempting to commit .env file(s). This is blocked for security."
echo " Blocked files:"
echo "$STAGED_ENV"
echo " If you need to update .env.example, that's allowed."
exit 1
fi

4
.gitignore vendored
View File

@@ -8,6 +8,7 @@ server/dist/
# Environment files # Environment files
.env .env
.env.local .env.local
.env.test
.env.*.local .env.*.local
**/.env **/.env
@@ -40,3 +41,6 @@ server/uploads/
# Playwright # Playwright
.playwright-mcp/ .playwright-mcp/
server/backups/ server/backups/
# Workbuddy
.workbuddy/

42
CODEBUDDY.md Normal file
View File

@@ -0,0 +1,42 @@
# Team: software-xiaocai-v2
小菜记账 (XiaoCai Bookkeeping) 微信小程序 v2 迭代团队。
## 目标
完成 v2 全量迭代安全性修复、代码质量重构、性能优化、UX 增强、测试体系建设、新功能开发。
## 任务总览
| 任务 | 优先级 | 状态 | 依赖 | 描述 |
|------|--------|------|------|------|
| T01-remaining | P0 | pending | 无 | 完成安全修复剩余项 (S1, S5, S6, S7, 基础设施) |
| T02 | P1 | pending | T01 | 代码质量重构 (C1-C9, 通用组件抽取) |
| T03 | P1 | pending | T01 | 性能优化与 UX 增强 (Perf-1~7, UX-1~7) |
| T04 | P1 | pending | T01 | 测试体系建设 (后端集成测试 + 前端 Store 测试) |
| T05 | P1 | pending | T01 | 新功能 (财务报告推送 + 预算预警) |
## T01 已完成项
- S2: logs.ts SQL 参数化 (LIMIT/OFFSET 改为 ? 占位符)
- S3(部分): TOKEN_SECRET 启动校验 checkTokenSecret()、backup.ts JWT_SECRET→TOKEN_SECRET、Access Token 有效期缩短为 2h
- S4: 导出下载使用 HMAC 签名短期凭证 (/prepare 端点)
## T01 剩余项
- S1: .env 凭据泄露修复 (轮换凭据 + git-filter-repo + pre-commit 钩子)
- S3(完善): TOKEN_SECRET 集中到 config/token.ts移除 auth.ts 内联 fallback
- S5: Refresh Token 双 Token 机制 (refresh_tokens 表 + 前端 401→refresh→重试)
- S6: 软删除 (users.deleted_at + admin 恢复接口 + 全局查询过滤)
- S7: DB 连接移除默认凭据 (connection.ts 移除 || 'localhost' 等 fallback)
- 基础设施: .env.test、jest.config.js、vitest.config.ts
## 并行策略
T01 完成后T02/T03/T04/T05 可并行推进。注意 T02 与 T03 有轻微文件冲突 (stores/),建议 T02 先行或约定合并策略。
## 参考文档
- PRD: `docs/prd-iteration-v2.md`
- 架构设计: `docs/arch-iteration-v2.md`
- 项目规范: `CLAUDE.md` + `DEV.md`

1
DEV.md
View File

@@ -119,6 +119,7 @@ onPullDownRefresh(async () => {
- Never 使用 git stash - Never 使用 git stash
- Never 切换分支 - Never 切换分支
- Never 过度封装,保持代码简洁明了 - Never 过度封装,保持代码简洁明了
- Never 未确定需求就开始实现
## Requirements ## Requirements

1209
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@
"dev:mp-weixin": "uni -p mp-weixin", "dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build -p h5", "build:h5": "uni build -p h5",
"build:mp-weixin": "uni build -p mp-weixin", "build:mp-weixin": "uni build -p mp-weixin",
"lint": "eslint src --ext .ts,.vue" "lint": "eslint src --ext .ts,.vue",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5010120260525001", "@dcloudio/uni-app": "3.0.0-alpha-5010120260525001",
@@ -25,12 +26,15 @@
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260525001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260525001",
"@dcloudio/uni-stacktracey": "3.0.0-alpha-5010120260525001", "@dcloudio/uni-stacktracey": "3.0.0-alpha-5010120260525001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260525001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260525001",
"@pinia/testing": "^0.1.7",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0", "@typescript-eslint/parser": "^6.19.0",
"@vue/test-utils": "^2.4.11",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"sass": "^1.70.0", "sass": "^1.70.0",
"typescript": "^5.3.0", "typescript": "^5.3.0",
"vite": "^5.0.0" "vite": "^5.0.0",
"vitest": "^1.6.1"
} }
} }

View File

@@ -50,7 +50,19 @@ export function updateUserRole(id: number, role: 'user' | 'admin') {
return request({ url: `/admin/users/${id}/status`, method: 'PUT', data: { role } }) return request({ url: `/admin/users/${id}/status`, method: 'PUT', data: { role } })
} }
/** 删除用户 */ export interface DeleteUserOptions {
export function deleteUser(id: number) { confirmName: string
return request({ url: `/admin/users/${id}`, method: 'DELETE' }) }
/** 删除用户 */
export function deleteUser(id: number, options: DeleteUserOptions) {
return request({ url: `/admin/users/${id}`, method: 'DELETE', data: options })
}
// 健康检查类型从 @/api/health 导入
export type { HealthStatus } from '@/api/health'
/** 获取服务健康状态(管理员) */
export function getHealth() {
return request<import('./health').HealthStatus>({ url: '/health' })
} }

View File

@@ -2,7 +2,9 @@ import { request } from '@/utils/request'
/** 登录响应 */ /** 登录响应 */
export interface LoginResult { export interface LoginResult {
token: string accessToken: string
refreshToken: string
expiresIn: number
userId: number userId: number
nickname: string nickname: string
avatar_url: string avatar_url: string
@@ -18,3 +20,12 @@ export function demoLogin() {
export function wxLogin(code: string) { export function wxLogin(code: string) {
return request<LoginResult>({ url: '/auth/login', method: 'POST', data: { code } }) return request<LoginResult>({ url: '/auth/login', method: 'POST', data: { code } })
} }
/** 刷新 Access Token */
export function refreshTokenApi(token: string) {
return request<{ accessToken: string; refreshToken: string; expiresIn: number }>({
url: '/auth/refresh',
method: 'POST',
data: { refreshToken: token }
})
}

38
client/src/api/backup.ts Normal file
View File

@@ -0,0 +1,38 @@
import { request } from '@/utils/request'
/** 备份记录 */
export interface Backup {
id: number
name: string
size: number
status: 'pending' | 'completed' | 'failed'
created_at: string
downloadToken?: string
}
/** 备份列表响应 */
export interface BackupList {
list: Backup[]
total: number
}
/** 获取备份列表 */
export function getBackups(params?: { page?: number; pageSize?: number }) {
return request<BackupList>({ url: '/backup', data: params })
}
/** 创建备份 */
export function createBackup() {
return request<{ id: number }>({ url: '/backup', method: 'POST' })
}
/** 删除备份 */
export function deleteBackup(id: number) {
return request({ url: `/backup/${id}`, method: 'DELETE' })
}
/** 获取备份下载地址 */
export function getBackupDownloadUrl(id: number, token: string): string {
const apiBase = typeof window !== 'undefined' ? '/api' : ''
return `${apiBase}/backup/${id}/download?token=${encodeURIComponent(token)}`
}

View File

@@ -14,6 +14,6 @@ export function getBudget(month?: string, group_id?: number | null) {
} }
/** 设置预算 */ /** 设置预算 */
export function setBudget(amount: number, month: string) { export function setBudget(amount: number, month: string, group_id?: number | null) {
return request<void>({ url: '/budget', method: 'POST', data: { amount, month } }) return request<void>({ url: '/budget', method: 'POST', data: { amount, month, group_id } })
} }

26
client/src/api/export.ts Normal file
View File

@@ -0,0 +1,26 @@
import { request } from '@/utils/request'
import { API_BASE } from '@/config'
/** 导出参数 */
export interface ExportParams {
startDate: string
endDate: string
type?: 'expense' | 'income' | 'all'
format?: 'csv' | 'json'
}
/** 准备导出:获取短期签名 token */
export function prepareExport(params: ExportParams) {
return request<{ token: string }>({
url: '/export/prepare',
data: { ...params, format: params.format || 'csv' }
})
}
/** 获取服务端导出下载 URL使用短期签名 token */
export async function getExportUrl(params: ExportParams): Promise<string> {
const { token } = await prepareExport(params)
const query = new URLSearchParams()
query.set('token', token)
return `${API_BASE}/export?${query.toString()}`
}

View File

@@ -1,5 +1,20 @@
import { request } from '@/utils/request' import { request } from '@/utils/request'
/** 反馈条目 */
export interface Feedback {
id: number
user_id: number
nickname?: string
avatar_url?: string
type: string
content: string
contact: string
status: 'pending' | 'processed' | 'ignored'
admin_reply?: string
created_at: string
updated_at?: string
}
/** 提交反馈 */ /** 提交反馈 */
export function submitFeedback(data: { export function submitFeedback(data: {
type: string type: string
@@ -11,10 +26,26 @@ export function submitFeedback(data: {
/** 获取反馈列表(管理员) */ /** 获取反馈列表(管理员) */
export function getFeedbackList(params?: { page?: number; pageSize?: number; status?: string }) { export function getFeedbackList(params?: { page?: number; pageSize?: number; status?: string }) {
return request({ url: '/feedback', data: params }) return request<{ list: Feedback[]; total: number; page: number; pageSize: number }>({ url: '/feedback', data: params })
}
/** 我的反馈条目(简化版) */
export interface MyFeedback {
id: number
type: string
content: string
contact: string
status: 'pending' | 'processed' | 'ignored'
admin_reply?: string
created_at: string
}
/** 获取我的反馈列表 */
export function getMyFeedbacks(params?: { page?: number; pageSize?: number }) {
return request<{ list: MyFeedback[]; total: number; page: number; pageSize: number }>({ url: '/feedback/mine', data: params })
} }
/** 更新反馈状态(管理员) */ /** 更新反馈状态(管理员) */
export function updateFeedbackStatus(id: number, status: string) { export function updateFeedbackStatus(id: number, status: Feedback['status'], admin_reply?: string) {
return request({ url: `/feedback/${id}/status`, method: 'PUT', data: { status } }) return request({ url: `/feedback/${id}/status`, method: 'PUT', data: { status, admin_reply } })
} }

15
client/src/api/health.ts Normal file
View File

@@ -0,0 +1,15 @@
import { request } from '@/utils/request'
/** 健康检查结果 */
export interface HealthStatus {
status: 'ok' | 'degraded' | 'error'
uptime: number
db: 'connected' | 'disconnected'
version: string
timestamp: string
}
/** 获取服务健康状态 */
export function getHealth() {
return request<HealthStatus>({ url: '/health' })
}

View File

@@ -60,7 +60,12 @@ export function queryLogs(params: {
page?: number page?: number
pageSize?: number pageSize?: number
}): Promise<{ lines: string[]; total: number; page: number; pageSize: number }> { }): Promise<{ lines: string[]; total: number; page: number; pageSize: number }> {
const { date, ...queryParams } = params const { date, ...rest } = params
// 过滤掉 undefined 值,防止被序列化为 "undefined" 字符串
const queryParams: Record<string, any> = {}
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined) queryParams[key] = value
}
return request({ url: `/logs/${date}`, data: queryParams }) return request({ url: `/logs/${date}`, data: queryParams })
} }
@@ -75,5 +80,12 @@ export function queryTrackEvents(params?: {
page?: number page?: number
pageSize?: number pageSize?: number
}): Promise<{ list: TrackEvent[]; total: number; page: number; pageSize: number }> { }): Promise<{ list: TrackEvent[]; total: number; page: number; pageSize: number }> {
return request({ url: '/logs/track/list', data: params }) // 过滤掉 undefined 值
const queryParams: Record<string, any> = {}
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) queryParams[key] = value
}
}
return request({ url: '/logs/track/list', data: queryParams })
} }

View File

@@ -0,0 +1,64 @@
import { request } from '@/utils/request'
/** 周期模板 */
export interface RecurringTemplate {
id: number
user_id: number
amount: number
type: 'expense' | 'income'
category_id: number
note: string
frequency: 'weekly' | 'monthly' | 'yearly'
next_date: string
end_date: string | null
is_active: number
group_id: number | null
category_name?: string
category_icon?: string
category_color?: string
created_at: string
updated_at: string
}
/** 获取周期模板列表 */
export function getRecurringTemplates() {
return request<RecurringTemplate[]>({ url: '/recurring' })
}
/** 创建周期模板 */
export function createRecurringTemplate(data: {
amount: number
type: 'expense' | 'income'
category_id: number
note?: string
frequency: 'weekly' | 'monthly' | 'yearly'
next_date: string
end_date?: string | null
group_id?: number | null
}) {
return request<{ id: number }>({ url: '/recurring', method: 'POST', data })
}
/** 更新周期模板 */
export function updateRecurringTemplate(id: number, data: {
amount?: number
type?: 'expense' | 'income'
category_id?: number
note?: string
frequency?: 'weekly' | 'monthly' | 'yearly'
next_date?: string
end_date?: string | null
is_active?: number
}) {
return request({ url: `/recurring/${id}`, method: 'PUT', data })
}
/** 删除周期模板 */
export function deleteRecurringTemplate(id: number) {
return request({ url: `/recurring/${id}`, method: 'DELETE' })
}
/** 同步周期账单(生成到期交易) */
export function syncRecurring() {
return request<{ generated: number }>({ url: '/recurring/sync', method: 'POST' })
}

View File

@@ -25,6 +25,7 @@ export interface CategoryStat {
export interface TrendPoint { export interface TrendPoint {
date: string date: string
amount: number amount: number
label?: string
} }
/** 获取概览数据 */ /** 获取概览数据 */
@@ -33,11 +34,35 @@ export function getOverview(params?: { month?: string; startDate?: string; endDa
} }
/** 获取分类统计 */ /** 获取分类统计 */
export function getCategoryStats(month?: string, type: string = 'expense', group_id?: number | null) { export function getCategoryStats(month?: string, type: string = 'expense', group_id?: number | null, period?: 'week' | 'month' | 'year') {
return request<CategoryStat[]>({ url: '/stats/category', data: { month, type, group_id } }) return request<CategoryStat[]>({ url: '/stats/category', data: { month, type, group_id, period } })
} }
/** 获取趋势数据 */ /** 获取趋势数据 */
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) { export function getTrend(month?: string, type: string = 'expense', group_id?: number | null, period?: 'week' | 'month' | 'year') {
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } }) return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id, period } })
}
/** 聚合统计(一次请求返回 overview + category + trend */
export interface DashboardData {
overview: Overview
category: CategoryStat[]
trend: TrendPoint[]
month: string
}
/** 获取聚合统计数据 */
export function getDashboard(params?: { month?: string; type?: string; period?: 'week' | 'month' | 'year'; group_id?: number | null }) {
return request<DashboardData>({ url: '/stats/dashboard', data: params })
}
/** 智能分类建议结果 */
export interface CategorySuggestion {
category: { id: number; name: string; color: string } | null
confidence: number
}
/** 根据金额和备注推荐分类 */
export function suggestCategory(amount: number, type: string, note?: string) {
return request<CategorySuggestion>({ url: '/stats/suggest-category', data: { amount, type, note } })
} }

51
client/src/api/tag.ts Normal file
View File

@@ -0,0 +1,51 @@
import { request } from '@/utils/request'
/** 标签 */
export interface Tag {
id: number
name: string
color: string
created_at: string
}
/** 标签统计 */
export interface TagStat {
tag_id: number
tag_name: string
tag_color: string
count: number
total_amount: number
}
export interface TagListResult {
list: Tag[]
}
/** 获取标签列表 */
export function getTags() {
return request<Tag[] | TagListResult>({ url: '/tags' })
}
/** 创建标签 */
export function createTag(data: { name: string; color: string }) {
return request<{ id: number }>({ url: '/tags', method: 'POST', data })
}
/** 更新标签 */
export function updateTag(id: number, data: { name?: string; color?: string }) {
return request({ url: `/tags/${id}`, method: 'PUT', data })
}
/** 删除标签 */
export function deleteTag(id: number) {
return request({ url: `/tags/${id}`, method: 'DELETE' })
}
/** 按标签统计 */
export function getTagStats(params?: {
month?: string
period?: 'week' | 'month' | 'year'
type?: 'expense' | 'income'
}) {
return request<TagStat[]>({ url: '/tags/stats/by-tag', data: params })
}

View File

@@ -15,6 +15,14 @@ export interface Transaction {
group_id?: number | null group_id?: number | null
creator_nickname?: string creator_nickname?: string
creator_avatar?: string creator_avatar?: string
tags?: TagItem[]
}
/** 标签简略信息 */
export interface TagItem {
id: number
name: string
color: string
} }
/** 交易列表响应 */ /** 交易列表响应 */
@@ -40,6 +48,7 @@ export interface TransactionParams {
minAmount?: number minAmount?: number
maxAmount?: number maxAmount?: number
keyword?: string keyword?: string
tagId?: number
} }
/** 获取交易列表 */ /** 获取交易列表 */
@@ -60,6 +69,7 @@ export function createTransaction(data: {
note?: string note?: string
date: string date: string
group_id?: number | null group_id?: number | null
tagIds?: number[]
}) { }) {
return request<{ id: number }>({ url: '/transactions', method: 'POST', data }) return request<{ id: number }>({ url: '/transactions', method: 'POST', data })
} }
@@ -71,6 +81,7 @@ export function updateTransaction(id: number, data: {
category_id?: number category_id?: number
note?: string note?: string
date: string date: string
tagIds?: number[]
}) { }) {
return request({ url: `/transactions/${id}`, method: 'PUT', data }) return request({ url: `/transactions/${id}`, method: 'PUT', data })
} }
@@ -79,3 +90,16 @@ export function updateTransaction(id: number, data: {
export function deleteTransaction(id: number) { export function deleteTransaction(id: number) {
return request({ url: `/transactions/${id}`, method: 'DELETE' }) return request({ url: `/transactions/${id}`, method: 'DELETE' })
} }
/** 导入交易记录 */
export function importTransactions(data: {
transactions: Array<{
date: string
type: 'expense' | 'income'
category_name: string
amount: number
note?: string
}>
}) {
return request<{ imported: number; skipped: number }>({ url: '/transactions/import', method: 'POST', data })
}

View File

@@ -0,0 +1,275 @@
<template>
<view class="amount-editor" :class="[`amount-editor--${size}`, { 'amount-editor--focused': innerFocused }]">
<view class="amount-display" @tap="!disabled && focusAtEnd()">
<text class="currency">¥</text>
<view class="amount-value">
<template v-if="displayChars.length > 0">
<template v-for="(char, index) in displayChars" :key="`${char}-${index}`">
<text v-if="innerFocused && cursorIndex === index" class="cursor"></text>
<text class="amount-char" @tap.stop="!disabled && focusAt(index + 1)">{{ char }}</text>
</template>
<text v-if="innerFocused && cursorIndex === displayChars.length" class="cursor"></text>
</template>
<template v-else>
<text class="placeholder" @tap.stop="!disabled && focusAtEnd()">{{ placeholder }}</text>
<text v-if="innerFocused" class="cursor"></text>
</template>
</view>
<text v-if="unit" class="unit">{{ unit }}</text>
</view>
<Numpad
v-model="innerValue"
v-model:cursorIndex="cursorIndex"
:max="max"
:fixed="fixed"
:visible="innerFocused && visible && !disabled"
@confirm="emit('confirm')"
/>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import Numpad from '@/components/Numpad/Numpad.vue'
import { clampCursorIndex } from '@/components/AmountEditor/amount-edit'
const props = withDefaults(defineProps<{
modelValue?: string
max?: number
fixed?: boolean
visible?: boolean
autoFocus?: boolean
size?: 'small' | 'medium' | 'large'
placeholder?: string
unit?: string
disabled?: boolean
}>(), {
modelValue: '',
max: 9999999.99,
fixed: true,
visible: true,
autoFocus: true,
size: 'large',
placeholder: '0',
unit: '',
disabled: false
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'focus'): void
(e: 'blur'): void
(e: 'confirm'): void
}>()
const innerFocused = ref(false)
const cursorIndex = ref(0)
const innerValue = computed({
get: () => props.modelValue || '',
set: (value: string) => emit('update:modelValue', value)
})
const displayChars = computed(() => innerValue.value.split(''))
watch(
() => props.modelValue,
(value) => {
cursorIndex.value = clampCursorIndex(value || '', cursorIndex.value)
}
)
watch(
() => props.visible,
(visible) => {
if (visible && props.autoFocus) {
nextTick(focus)
} else if (!visible) {
blur()
}
},
{ immediate: true }
)
function focus() {
if (props.disabled || !props.visible) return
cursorIndex.value = innerValue.value.length
if (!innerFocused.value) {
innerFocused.value = true
emit('focus')
}
}
function blur() {
if (innerFocused.value) {
innerFocused.value = false
emit('blur')
}
}
function focusAt(index: number) {
if (props.disabled || !props.visible) return
cursorIndex.value = clampCursorIndex(innerValue.value, index)
if (!innerFocused.value) {
innerFocused.value = true
emit('focus')
}
}
function focusAtEnd() {
focusAt(innerValue.value.length)
}
defineExpose({ focus, blur })
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.amount-editor {
width: 100%;
}
// 金额显示区 — 未聚焦时低调,聚焦时粘土卡片浮起
.amount-display {
display: flex;
align-items: baseline;
justify-content: center;
gap: 8rpx;
padding: $space-lg $space-xl $space-md;
border-radius: $radius-xl;
border: 2rpx solid transparent;
background: transparent;
transition: background $transition-normal, border-color $transition-normal, box-shadow $transition-normal;
-webkit-tap-highlight-color: transparent;
}
.amount-editor--focused .amount-display {
background: $surface;
border-color: $border;
box-shadow: $shadow-clay;
}
// ¥ 符号
.currency {
flex-shrink: 0;
font-family: 'Fredoka', sans-serif;
font-weight: 500;
color: $text-muted;
line-height: 1.2;
}
// 单位(元)
.unit {
flex-shrink: 0;
font-weight: 600;
color: $text-sec;
line-height: 1.2;
}
// 金额字符容器
.amount-value {
min-width: 0;
display: flex;
align-items: baseline;
justify-content: center;
flex-wrap: wrap;
font-family: 'Fredoka', sans-serif;
font-weight: 700;
color: $text;
line-height: 1.2;
word-break: break-all;
}
.amount-char,
.placeholder {
display: inline-flex;
align-items: center;
}
.amount-char {
-webkit-tap-highlight-color: transparent;
}
.placeholder {
color: #D0C4C4;
}
// 光标
.cursor {
width: 4rpx;
height: 1em;
margin: 0 2rpx;
border-radius: 999rpx;
background: $primary;
animation: cursor-blink 1s steps(2, start) infinite;
}
// ===== 尺寸变体 =====
.amount-editor--small {
.amount-display {
padding: $space-md $space-lg;
}
.amount-value {
font-size: 52rpx;
}
.currency {
font-size: 28rpx;
}
.unit {
font-size: $font-sm;
}
}
.amount-editor--medium {
.amount-display {
padding: $space-md $space-xl;
}
.amount-value {
font-size: 68rpx;
}
.currency {
font-size: 34rpx;
}
.unit {
font-size: $font-md;
}
}
.amount-editor--large {
.amount-value {
font-size: 88rpx;
}
.currency {
font-size: 40rpx;
}
.unit {
font-size: $font-lg;
}
}
@keyframes cursor-blink {
0%, 45% { opacity: 1; }
46%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.amount-display {
transition: none;
}
.cursor {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,118 @@
export type AmountKey = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '00' | '.'
export type AmountActionKey = AmountKey | 'delete'
export interface AmountEditOptions {
max?: number
maxLength?: number
}
export interface AmountEditResult {
value: string
cursorIndex: number
accepted: boolean
}
const DEFAULT_MAX = 9999999.99
// 对应最大金额字符串 '9999999.99' 的长度。
const DEFAULT_MAX_LENGTH = 10
const MAX_DECIMAL_PLACES = 2
export function clampCursorIndex(value: string, cursorIndex: number): number {
if (cursorIndex < 0) return 0
if (cursorIndex > value.length) return value.length
return cursorIndex
}
export function insertAmountKey(
value: string,
cursorIndex: number,
key: AmountKey,
options: AmountEditOptions = {}
): AmountEditResult {
const safeCursor = clampCursorIndex(value, cursorIndex)
// 替换模式:当当前值只有一个 '0' 且光标在末尾时,非 '.' 输入应替换 '0'
if (value === '0' && safeCursor === value.length && key !== '.') {
// key 可能是 '00',需要规范化
const insert = normalizeInsert('', 0, key)
if (insert === '') return { value, cursorIndex: safeCursor, accepted: true }
const next = insert
if (!isValidAmountInput(next, options)) {
return { value, cursorIndex: safeCursor, accepted: false }
}
return {
value: next,
cursorIndex: insert.length,
accepted: true,
}
}
const normalizedInsert = normalizeInsert(value, safeCursor, key)
let next = value.slice(0, safeCursor) + normalizedInsert + value.slice(safeCursor)
if (!isValidAmountInput(next, options)) {
// 小数位超出上限时,从右侧挤出一个字符(小数部分左移)
const dotIdx = next.indexOf('.')
if (dotIdx >= 0 && next.length - dotIdx - 1 > MAX_DECIMAL_PLACES) {
next = next.slice(0, -1)
const newCursor = Math.min(safeCursor + normalizedInsert.length, next.length)
if (!isValidAmountInput(next, options)) {
return { value, cursorIndex: safeCursor, accepted: false }
}
return { value: next, cursorIndex: newCursor, accepted: true }
}
return { value, cursorIndex: safeCursor, accepted: false }
}
return {
value: next,
cursorIndex: safeCursor + normalizedInsert.length,
accepted: true,
}
}
export function deleteAmountChar(value: string, cursorIndex: number): AmountEditResult {
const safeCursor = clampCursorIndex(value, cursorIndex)
if (safeCursor === 0) {
return { value, cursorIndex: safeCursor, accepted: false }
}
return {
value: value.slice(0, safeCursor - 1) + value.slice(safeCursor),
cursorIndex: safeCursor - 1,
accepted: true,
}
}
export function applyAmountKey(
value: string,
cursorIndex: number,
key: AmountActionKey,
options: AmountEditOptions = {}
): AmountEditResult {
if (key === 'delete') return deleteAmountChar(value, cursorIndex)
return insertAmountKey(value, cursorIndex, key, options)
}
export function isValidAmountInput(value: string, options: AmountEditOptions = {}): boolean {
if (value === '') return true
if (value.length > (options.maxLength ?? DEFAULT_MAX_LENGTH)) return false
if (!/^\d*(\.\d*)?$/.test(value)) return false
const dotIndex = value.indexOf('.')
if (dotIndex >= 0 && value.length - dotIndex - 1 > MAX_DECIMAL_PLACES) return false
const num = parseFloat(value)
if (!Number.isNaN(num) && num > (options.max ?? DEFAULT_MAX)) return false
return true
}
function normalizeInsert(value: string, cursorIndex: number, key: AmountKey): string {
if ((key === '0' || key === '00') && value === '') return '0'
if ((key === '0' || key === '00') && value === '0' && cursorIndex === value.length) return ''
if (value === '0' && cursorIndex === value.length && key !== '.') return key
// 空值时输入小数点,补前导零,保证合法格式
if (value === '' && key === '.') return '0.'
return key
}

View File

@@ -1,7 +1,7 @@
<template> <template>
<view class="budget-bar-wrap"> <view class="budget-bar-wrap">
<view class="bar-track"> <view class="bar-track">
<view class="bar-fill" :style="{ width: percentage + '%', background: fillColor }"></view> <view class="bar-fill" :class="{ over: rawPercentage > 100 }" :style="{ width: percentage + '%' }"></view>
</view> </view>
<view class="bar-info" v-if="showLabel"> <view class="bar-info" v-if="showLabel">
<text class="bar-text" v-if="rawPercentage <= 100">预算 {{ formatAmount(total) }} · 剩余 {{ formatAmount(Math.max(0, total - used)) }}</text> <text class="bar-text" v-if="rawPercentage <= 100">预算 {{ formatAmount(total) }} · 剩余 {{ formatAmount(Math.max(0, total - used)) }}</text>
@@ -27,25 +27,25 @@ const rawPercentage = computed(() => {
}) })
const percentage = computed(() => Math.min(100, rawPercentage.value)) const percentage = computed(() => Math.min(100, rawPercentage.value))
const fillColor = computed(() => {
if (rawPercentage.value > 100) return '#FF6B6B'
return 'linear-gradient(90deg, #FF8C69, #FFB89A)'
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.bar-track { .bar-track {
height: 16rpx; height: 16rpx;
border-radius: 8rpx; border-radius: 8rpx;
background: #F0E0D6; background: $border;
overflow: hidden; overflow: hidden;
} }
.bar-fill { .bar-fill {
height: 100%; height: 100%;
border-radius: 8rpx; border-radius: 8rpx;
transition: width 0.6s ease-out; background: linear-gradient(90deg, $primary, lighten($primary, 15%));
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
&.over {
background: $danger;
}
} }
.bar-info { .bar-info {
@@ -56,20 +56,20 @@ const fillColor = computed(() => {
.bar-text { .bar-text {
font-size: 24rpx; font-size: 24rpx;
color: #8B7E7E; color: $text-sec;
} }
.bar-pct { .bar-pct {
font-family: 'Fredoka', sans-serif; font-family: 'Fredoka', sans-serif;
font-size: 28rpx; font-size: 28rpx;
font-weight: 500; font-weight: 500;
color: #FF8C69; color: $primary;
&.over { color: #FF6B6B; } &.over { color: $danger; }
} }
.bar-text.over { .bar-text.over {
color: #FF6B6B; color: $danger;
font-weight: 500; font-weight: 500;
} }
</style> </style>

View File

@@ -22,6 +22,7 @@ const iconStyle = computed(() => ({
background: props.selected ? '#FFE8E0' : (props.bgColor || (props.color + '15')), background: props.selected ? '#FFE8E0' : (props.bgColor || (props.color + '15')),
width: (sizeMap[props.size || 'md']) + 'rpx', width: (sizeMap[props.size || 'md']) + 'rpx',
height: (sizeMap[props.size || 'md']) + 'rpx', height: (sizeMap[props.size || 'md']) + 'rpx',
boxShadow: props.selected ? '0 0 0 4rpx rgba(255, 140, 105, 0.15)' : 'none',
})) }))
const fontSize = computed(() => (fontMap[props.size || 'md']) + 'rpx') const fontSize = computed(() => (fontMap[props.size || 'md']) + 'rpx')
@@ -42,6 +43,7 @@ const fontSize = computed(() => (fontMap[props.size || 'md']) + 'rpx')
} }
.selected { .selected {
border-color: #FF8C69; border-color: $primary;
transform: scale(1.05);
} }
</style> </style>

View File

@@ -0,0 +1,47 @@
<template>
<view class="color-picker">
<view
v-for="c in colors" :key="c"
class="color-dot"
:class="{ selected: modelValue === c }"
:style="{ background: c }"
@tap="$emit('update:modelValue', c)"
/>
</view>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: string
colors?: string[]
}>(), {
colors: () => ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
})
defineEmits<{
'update:modelValue': [color: string]
}>()
</script>
<style lang="scss" scoped>
.color-picker {
display: flex;
gap: $space-lg;
margin-bottom: $space-md;
flex-wrap: wrap;
}
.color-dot {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid transparent;
transition: all $transition-normal;
&.selected {
border-color: $text;
transform: scale(1.15);
}
&:active { opacity: 0.7; }
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<view class="drag-sort-list">
<movable-area class="drag-area">
<movable-view
v-for="(item, index) in list"
:key="itemKey ? item[itemKey] : index"
class="drag-item"
:class="{ dragging: dragIndex === index }"
direction="vertical"
:y="positions[index] || 0"
:damping="40"
:disabled="disabled"
@touchstart="onDragStart(index)"
@change="onChange(index, $event)"
@touchend="onDragEnd(index)"
>
<view class="drag-handle" v-if="!disabled">
<Icon name="dragHandle" :size="28" color="#BFB3B3" />
</view>
<slot name="item" :item="item" :index="index">
<text class="drag-default-text">{{ item[itemKey] || item }}</text>
</slot>
</movable-view>
</movable-area>
</view>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import Icon from '@/components/Icon/Icon.vue'
const props = withDefaults(defineProps<{
list: any[]
itemKey?: string
disabled?: boolean
itemHeight?: number
}>(), {
disabled: false,
itemHeight: 56
})
const emit = defineEmits<{
(e: 'sort', newOrder: any[]): void
}>()
/** 每个 item 的 y 坐标 */
const positions = ref<number[]>([])
/** 当前正在拖拽的 index */
const dragIndex = ref<number | null>(null)
/** 拖拽起始 y */
let startY = 0
/** 当前排序列表 */
const sortedList = ref<any[]>([])
watch(() => props.list, (newList) => {
sortedList.value = [...newList]
recalcPositions()
}, { immediate: true, deep: true })
function recalcPositions() {
const pos: number[] = []
for (let i = 0; i < sortedList.value.length; i++) {
pos.push(i * props.itemHeight)
}
positions.value = pos
}
function onDragStart(index: number) {
dragIndex.value = index
startY = positions.value[index] || 0
}
function onChange(index: number, e: any) {
if (e.detail.source !== 'touch') return
const newY = e.detail.y
const currentPos = index * props.itemHeight
const delta = newY - currentPos
const swapThreshold = props.itemHeight * 0.5
if (Math.abs(delta) > swapThreshold) {
const direction = delta > 0 ? 1 : -1
const swapIndex = index + direction
if (swapIndex < 0 || swapIndex >= sortedList.value.length) return
// 交换元素
const newList = [...sortedList.value]
const temp = newList[index]
newList[index] = newList[swapIndex]
newList[swapIndex] = temp
sortedList.value = newList
recalcPositions()
emit('sort', newList)
}
}
function onDragEnd(index: number) {
dragIndex.value = null
}
</script>
<style lang="scss" scoped>
.drag-sort-list {
width: 100%;
}
.drag-area {
width: 100%;
height: auto;
min-height: 200rpx;
}
.drag-item {
width: 100%;
display: flex;
align-items: center;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
margin-bottom: $space-xs;
transition: box-shadow $transition-normal;
&.dragging {
box-shadow: $shadow-lg;
opacity: 0.9;
z-index: 10;
}
}
.drag-handle {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&:active { opacity: 0.6; }
}
.drag-default-text {
font-size: $font-lg;
color: $text;
flex: 1;
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<view class="modal-mask" v-if="visible" @tap="$emit('update:visible', false)">
<view class="modal" @tap.stop>
<text class="modal-title">{{ title }}</text>
<template v-for="field in fields" :key="field.key">
<input
v-if="field.type === 'text'"
class="modal-input"
:value="form[field.key]"
:placeholder="field.placeholder || field.label"
:maxlength="field.maxlength || 50"
@input="form[field.key] = ($event as any).detail.value"
/>
<ColorPicker
v-if="field.type === 'color'"
:modelValue="form[field.key] || colors[0]"
@update:modelValue="form[field.key] = $event"
:colors="colors"
/>
</template>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="$emit('update:visible', false)">取消</view>
<view class="modal-btn confirm" :class="{ disabled: !canConfirm }" @tap="onConfirm">确定</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { reactive, computed, watch } from 'vue'
import ColorPicker from '@/components/ColorPicker/ColorPicker.vue'
export interface EditField {
key: string
label: string
type: 'text' | 'color'
placeholder?: string
maxlength?: number
}
const props = withDefaults(defineProps<{
visible: boolean
title: string
fields: EditField[]
modelValue: Record<string, any>
colors?: string[]
}>(), {
colors: () => ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
})
const emit = defineEmits<{
'update:visible': [val: boolean]
'confirm': [data: Record<string, any>]
}>()
const form = reactive<Record<string, any>>({})
// 当 visible 变为 true 时,用 modelValue 初始化 form
watch(() => props.visible, (val) => {
if (val) {
Object.keys(form).forEach(k => delete form[k])
Object.assign(form, { ...props.modelValue })
}
})
const canConfirm = computed(() => {
// 至少有一个 text 字段不为空
return props.fields
.filter(f => f.type === 'text')
.every(f => (form[f.key] || '').toString().trim().length > 0)
})
function onConfirm() {
if (!canConfirm.value) return
// 只返回 fields 中定义的字段
const data: Record<string, any> = {}
props.fields.forEach(f => { data[f.key] = form[f.key] || '' })
emit('confirm', data)
}
</script>
<style lang="scss" scoped>
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
width: 600rpx;
background: $surface;
border-radius: $space-lg;
padding: $space-2xl;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
display: block;
text-align: center;
margin-bottom: $space-lg;
}
.modal-input {
height: 96rpx;
background: $bg;
border-radius: $space-md;
padding: 0 $space-lg;
font-size: $font-lg;
margin-bottom: $space-md;
}
.modal-btns {
display: flex;
gap: $space-md;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: $space-md;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-lg;
font-weight: 600;
&.cancel {
background: $bg;
color: $text-sec;
}
&.confirm {
background: linear-gradient(135deg, $primary, #E67355);
color: $surface;
}
&.disabled { opacity: 0.5; }
&:active { opacity: 0.8; }
}
</style>

View File

@@ -50,6 +50,7 @@ const iconMap: Record<string, Record<string, string>> = {
}, },
check: { check: {
'#FFFFFF': '/static/icons/check-white.png', '#FFFFFF': '/static/icons/check-white.png',
'#FF8C69': '/static/icons/check-orange.png',
}, },
trash: { trash: {
'#FF8C69': '/static/icons/trash-orange.png', '#FF8C69': '/static/icons/trash-orange.png',
@@ -71,6 +72,25 @@ const iconMap: Record<string, Record<string, string>> = {
'#8B7E7E': '/static/icons/settings-gray.png', '#8B7E7E': '/static/icons/settings-gray.png',
'#FF8C69': '/static/icons/settings-orange.png', '#FF8C69': '/static/icons/settings-orange.png',
}, },
tag: {
'#8B7E7E': '/static/icons/tag-gray.png',
'#FF8C69': '/static/icons/tag-orange.png',
'#BFB3B3': '/static/icons/tag-light.png',
},
archive: {
'#7BC67E': '/static/icons/archive-green.png',
'#BFB3B3': '/static/icons/archive-light.png',
},
download: {
'#5B9BD5': '/static/icons/download-blue.png',
},
upload: {
'#FF8C69': '/static/icons/upload-orange.png',
},
dragHandle: {
'#BFB3B3': '/static/icons/drag-handle-light.png',
'#8B7E7E': '/static/icons/drag-handle-gray.png',
},
} }
const iconSrc = computed(() => { const iconSrc = computed(() => {

View File

@@ -1,9 +1,15 @@
<template> <template>
<view class="numpad"> <view class="numpad" :class="{ 'numpad-fixed': fixed, 'numpad-hidden': !visible }">
<view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)"> <view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)">
<text class="key-text">{{ key }}</text> <text class="key-text">{{ key }}</text>
</view> </view>
<view class="key fn" @tap="onDelete"> <view
class="key fn"
@tap="onDeleteTap"
@touchstart="onDeleteTouchStart"
@touchend="onDeleteTouchEnd"
@touchcancel="onDeleteTouchEnd"
>
<view class="backspace-icon"> <view class="backspace-icon">
<view class="backspace-arrow"></view> <view class="backspace-arrow"></view>
<view class="backspace-line"></view> <view class="backspace-line"></view>
@@ -16,73 +22,118 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onBeforeUnmount } from 'vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import {
applyAmountKey,
clampCursorIndex,
type AmountKey
} from '@/components/AmountEditor/amount-edit'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
/** 金额字符串v-model */ /** 金额字符串v-model */
modelValue?: string modelValue?: string
/** 光标位置 */
cursorIndex?: number
/** 最大金额(元) */ /** 最大金额(元) */
max?: number max?: number
/** 是否固定在底部(弹窗中使用时设为 false */
fixed?: boolean
/** 是否显示fixed 模式下控制滑入/滑出) */
visible?: boolean
}>(), { }>(), {
modelValue: '', modelValue: '',
max: 9999999.99 max: 9999999.99,
fixed: true,
visible: true
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void (e: 'update:modelValue', value: string): void
(e: 'update:cursorIndex', index: number): void
(e: 'confirm'): void (e: 'confirm'): void
}>() }>()
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00'] const keys: AmountKey[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
const amountStr = computed(() => props.modelValue) const amountStr = computed(() => props.modelValue)
const effectiveCursorIndex = computed(() =>
clampCursorIndex(amountStr.value, props.cursorIndex ?? amountStr.value.length)
)
let deleteTimer: ReturnType<typeof setTimeout> | null = null
let deleteLongPressed = false
function vibrate() { function vibrate() {
try { uni.vibrateShort({ type: 'light' }) } catch {} try { uni.vibrateShort({ type: 'light' }) } catch {}
} }
function emitValue(val: string) { function applyKey(key: AmountKey | 'delete') {
emit('update:modelValue', val)
}
function onKeyTap(key: string) {
vibrate() vibrate()
const current = amountStr.value const result = applyAmountKey(
amountStr.value,
if (key === '.') { effectiveCursorIndex.value,
if (current.includes('.')) return key,
if (!current || current === '0') { emitValue('0.'); return } { max: props.max }
} )
if (result.accepted) {
let next: string emit('update:modelValue', result.value)
if (!current || current === '0') { emit('update:cursorIndex', result.cursorIndex)
next = (key === '0' || key === '00') ? '0' : key
} else {
next = current + key
}
if (next.includes('.')) {
const decimals = next.split('.')[1]
if (decimals && decimals.length > 2) return
}
if (next.length > 10) return
const num = parseFloat(next)
if (!isNaN(num) && num > props.max) return
emitValue(next)
}
function onDelete() {
vibrate()
const current = amountStr.value
if (current.length > 1) {
emitValue(current.slice(0, -1))
} else {
emitValue('')
} }
} }
function onKeyTap(key: AmountKey) {
applyKey(key)
}
function onDeleteTap() {
if (deleteLongPressed) {
deleteLongPressed = false
return
}
applyKey('delete')
}
function doDeleteRepeat() {
deleteLongPressed = true
const result = applyAmountKey(
amountStr.value,
effectiveCursorIndex.value,
'delete',
{ max: props.max }
)
if (result.accepted) {
vibrate()
emit('update:modelValue', result.value)
emit('update:cursorIndex', result.cursorIndex)
deleteTimer = setTimeout(doDeleteRepeat, 120)
} else {
deleteLongPressed = false
deleteTimer = null
}
}
function onDeleteTouchStart() {
if (deleteTimer !== null) clearTimeout(deleteTimer)
deleteLongPressed = false
deleteTimer = setTimeout(doDeleteRepeat, 400)
}
function onDeleteTouchEnd() {
if (deleteTimer !== null) {
clearTimeout(deleteTimer)
deleteTimer = null
}
}
onBeforeUnmount(() => {
if (deleteTimer !== null) {
clearTimeout(deleteTimer)
deleteTimer = null
}
})
function onConfirm() { function onConfirm() {
vibrate() vibrate()
emit('confirm') emit('confirm')
@@ -96,18 +147,55 @@ function onConfirm() {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: $space-sm; gap: $space-sm;
padding: 0 $space-xl; padding: $space-sm $space-xl;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom); // 非 fixed 模式的显示/隐藏
&:not(.numpad-fixed) {
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s ease;
overflow: hidden;
max-height: 600rpx;
&.numpad-hidden {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
}
}
// fixed 模式的滑入/滑出
&.numpad-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: $bg;
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.numpad-hidden {
transform: translateY(100%);
}
}
} }
.key { .key {
height: 96rpx; height: 96rpx;
border-radius: $radius-xl; border-radius: $radius-xl;
background: $bg; background: $surface;
border: 2rpx solid $border;
box-shadow: $shadow-clay;
@include flex-center; @include flex-center;
transition: all $transition-fast;
-webkit-tap-highlight-color: transparent;
user-select: none;
&:active { background: $border; } &:active {
background: $surface-warm;
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
} }
.key-text { .key-text {
@@ -119,13 +207,25 @@ function onConfirm() {
.key.fn { .key.fn {
background: $primary-light; background: $primary-light;
&:active { background: #FFD0C0; } border-color: darken($primary-light, 8%);
&:active {
background: darken($primary-light, 8%);
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
} }
.key.eq { .key.eq {
background: linear-gradient(135deg, $primary, $primary-dark); background: linear-gradient(135deg, $primary, $primary-dark);
box-shadow: 0 4rpx 16rpx rgba(255, 140, 105, 0.3); border: none;
&:active { background: $primary-dark; } box-shadow: $shadow-md, 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
&:active {
background: $primary-dark;
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
} }
// 自绘 backspace 图标 // 自绘 backspace 图标

View File

@@ -1,8 +1,22 @@
<template> <template>
<view class="save-success" v-if="visible"> <view class="save-success" v-if="visible">
<!-- 纸屑粒子 -->
<view class="confetti-container" v-if="animating">
<view
v-for="i in 20"
:key="i"
class="confetti"
:class="`confetti-${i}`"
:style="confettiStyle(i)"
></view>
</view>
<view class="success-content" :class="{ show: animating }"> <view class="success-content" :class="{ show: animating }">
<view class="success-icon"> <view class="success-icon-wrap">
<Icon name="check" :size="80" color="#FFFFFF" /> <view class="success-ring"></view>
<view class="success-icon">
<Icon name="check" :size="80" color="#FFFFFF" />
</view>
</view> </view>
<text class="success-text">{{ text }}</text> <text class="success-text">{{ text }}</text>
<view class="success-actions" v-if="showContinue"> <view class="success-actions" v-if="showContinue">
@@ -31,12 +45,33 @@ const emit = defineEmits(['update:visible', 'continue', 'back'])
const animating = ref(false) const animating = ref(false)
// 纸屑样式:随机位置、颜色、动画延迟
const confettiColors = ['#FF8C69', '#FFD166', '#7BC67E', '#6C9BCF', '#FF69B4', '#FFB89A']
function confettiStyle(i: number) {
const angle = (i / 20) * 360
const distance = 120 + Math.random() * 200
const color = confettiColors[i % confettiColors.length]
const delay = Math.random() * 0.3
const size = 8 + Math.random() * 12
const isRect = i % 3 === 0
return {
'--tx': `${Math.cos(angle * Math.PI / 180) * distance}rpx`,
'--ty': `${Math.sin(angle * Math.PI / 180) * distance - 100}rpx`,
'--rot': `${Math.random() * 720 - 360}deg`,
background: color,
width: isRect ? `${size}rpx` : `${size * 0.6}rpx`,
height: `${size}rpx`,
borderRadius: isRect ? '2rpx' : '50%',
animationDelay: `${delay}s`,
}
}
watch(() => props.visible, (val) => { watch(() => props.visible, (val) => {
if (val) { if (val) {
setTimeout(() => { animating.value = true }, 50) setTimeout(() => { animating.value = true }, 50)
// 如果不显示继续按钮,自动关闭 // 如果不显示继续按钮,自动关闭
if (!props.showContinue) { if (!props.showContinue) {
setTimeout(() => { onBack() }, 2000) setTimeout(() => { onBack() }, 2200)
} }
} else { } else {
animating.value = false animating.value = false
@@ -74,13 +109,44 @@ function onBack() {
z-index: 9999; z-index: 9999;
} }
// 纸屑容器
.confetti-container {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
z-index: 1;
}
.confetti {
position: absolute;
opacity: 0;
animation: confetti-burst 0.8s cubic-bezier(0.23, 1, 0.32, 1) forwards;
}
@keyframes confetti-burst {
0% {
transform: translate(0, 0) rotate(0deg) scale(0);
opacity: 0;
}
20% {
opacity: 1;
}
100% {
transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(1);
opacity: 0;
}
}
.success-content { .success-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
transform: scale(0.8); transform: scale(0.8);
opacity: 0; opacity: 0;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 2;
&.show { &.show {
transform: scale(1); transform: scale(1);
@@ -88,12 +154,42 @@ function onBack() {
} }
} }
// 图标外环脉冲
.success-icon-wrap {
position: relative;
@include flex-center;
}
.success-ring {
position: absolute;
width: 200rpx;
height: 200rpx;
border-radius: 50%;
border: 4rpx solid rgba(123, 198, 126, 0.3);
animation: ring-pulse 1s ease-out 0.3s both;
}
@keyframes ring-pulse {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
.success-icon { .success-icon {
width: 160rpx; width: 160rpx;
height: 160rpx; height: 160rpx;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, $success, #5BA85E); background: linear-gradient(135deg, $success, darken($success, 10%));
@include flex-center; @include flex-center;
box-shadow: 0 8rpx 32rpx rgba(123, 198, 126, 0.4);
} }
.success-text { .success-text {
@@ -114,15 +210,16 @@ function onBack() {
border-radius: $radius-xl; border-radius: $radius-xl;
min-width: 200rpx; min-width: 200rpx;
@include flex-center; @include flex-center;
transition: transform $transition-fast, opacity $transition-fast;
&.continue { &.continue {
background: $primary; background: $primary;
&:active { opacity: 0.8; } &:active { opacity: 0.8; transform: scale(0.96); }
} }
&.back { &.back {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
&:active { background: rgba(255, 255, 255, 0.3); } &:active { background: rgba(255, 255, 255, 0.3); transform: scale(0.96); }
} }
} }
@@ -131,4 +228,10 @@ function onBack() {
font-weight: 600; font-weight: 600;
color: $surface; color: $surface;
} }
@media (prefers-reduced-motion: reduce) {
.confetti { animation: none !important; display: none; }
.success-ring { animation: none !important; display: none; }
.success-content { transition: opacity 0.2s ease; }
}
</style> </style>

View File

@@ -19,7 +19,7 @@ const style = computed(() => ({
<style lang="scss" scoped> <style lang="scss" scoped>
.skeleton { .skeleton {
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%); background: linear-gradient(90deg, $border 25%, $surface-warm 50%, $border 75%);
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 1.5s infinite; animation: shimmer 1.5s infinite;
border-radius: 8rpx; border-radius: 8rpx;

View File

@@ -0,0 +1,65 @@
<template>
<view class="snackbar" :class="{ show: visible, hide: !visible }">
<text class="snackbar-text">{{ message }}</text>
<view class="snackbar-action" @tap="$emit('undo')" v-if="showUndo">
<text class="snackbar-action-text">撤销</text>
</view>
</view>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
visible: boolean
message: string
showUndo?: boolean
}>(), { showUndo: true })
defineEmits<{ undo: [] }>()
</script>
<style lang="scss" scoped>
.snackbar {
position: fixed;
bottom: calc(120rpx + env(safe-area-inset-bottom));
left: $space-xl;
right: $space-xl;
background: #333;
color: #fff;
border-radius: $radius-md;
padding: $space-md $space-xl;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 999;
transform: translateY(200rpx);
opacity: 0;
transition: all $transition-normal;
&.show {
transform: translateY(0);
opacity: 1;
}
&.hide {
transform: translateY(200rpx);
opacity: 0;
}
}
.snackbar-text {
font-size: $font-lg;
flex: 1;
}
.snackbar-action {
padding: $space-xs $space-md;
border-radius: $radius-xs;
&:active { opacity: 0.7; }
}
.snackbar-action-text {
font-size: $font-lg;
font-weight: 600;
color: $primary;
}
</style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<view class="tx-item-wrap"> <view class="tx-item-wrap" :class="{ 'show-hint': swipeHint && !disableSwipe }">
<view <view
class="tx-item" class="tx-item"
:style="{ transform: `translateX(${offsetX}rpx)` }" :style="{ transform: `translateX(${offsetX}rpx)` }"
@@ -16,7 +16,9 @@
size="sm" size="sm"
/> />
<view class="info"> <view class="info">
<text class="name">{{ item.note || item.category_name || '未分类' }}</text> <view class="name-row">
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
</view>
<view class="meta"> <view class="meta">
<view v-if="isOtherCreator" class="creator-tag"> <view v-if="isOtherCreator" class="creator-tag">
<image v-if="creatorAvatarUrl" :src="creatorAvatarUrl" class="creator-mini-avatar" mode="aspectFill" /> <image v-if="creatorAvatarUrl" :src="creatorAvatarUrl" class="creator-mini-avatar" mode="aspectFill" />
@@ -37,7 +39,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { formatAmount, formatDate } from '@/utils/format' import { formatAmount, formatDate } from '@/utils/format'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
@@ -61,22 +63,19 @@ const props = withDefaults(defineProps<{
showCreator?: boolean showCreator?: boolean
/** 当前用户 ID用于判断是否为本人记录 */ /** 当前用户 ID用于判断是否为本人记录 */
currentUserId?: number currentUserId?: number
}>(), { hideDate: false, disableSwipe: false, showCreator: false, currentUserId: 0 }) /** 是否显示左滑提示动画由父组件控制仅首个可删除项传入true */
swipeHint?: boolean
}>(), { hideDate: false, disableSwipe: false, showCreator: false, currentUserId: 0, swipeHint: false })
/** 是否为他人的记录(群组模式下,非本人的才显示创建者信息) */ /** 是否为他人的记录(群组模式下,非本人的才显示创建者信息) */
const isOtherCreator = computed(() => { const isOtherCreator = computed(() => {
if (!props.showCreator || !props.item.creator_nickname) return false return !!props.showCreator && !!props.currentUserId && props.item.user_id !== props.currentUserId
return !props.currentUserId || props.item.user_id !== props.currentUserId
}) })
defineEmits(['item-tap', 'delete']) defineEmits(['item-tap', 'delete'])
const creatorAvatarUrl = computed(() => { const creatorAvatarUrl = computed(() => {
if (!props.item.creator_avatar) return '' return getAvatarUrl(props.item.creator_avatar || '')
if (props.item.creator_avatar.startsWith('http') || props.item.creator_avatar.startsWith('blob:')) return props.item.creator_avatar
// 截取服务端根地址(去掉 /api
const base = API_BASE.replace(/\/api$/, '')
return base + '/api/user/avatar/' + props.item.creator_avatar
}) })
const startX = ref(0) const startX = ref(0)
@@ -108,6 +107,8 @@ function onTouchEnd() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/styles/mixins.scss';
.tx-item-wrap { .tx-item-wrap {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -121,22 +122,33 @@ function onTouchEnd() {
gap: 24rpx; gap: 24rpx;
border-radius: 20rpx; border-radius: 20rpx;
transition: transform 0.2s ease; transition: transform 0.2s ease;
background: #FFFFFF; background: $surface;
position: relative; position: relative;
z-index: 1; z-index: 1;
&:active { &:active {
background: #FFF0E6; background: $surface-warm;
} }
} }
// 左滑提示:仅在 swipeHint=true 且 disableSwipe=false 时播放一次
.show-hint .tx-item {
animation: swipe-hint 0.6s ease 1.5s both;
}
@keyframes swipe-hint {
0% { transform: translateX(0); }
40% { transform: translateX(-40rpx); }
100% { transform: translateX(0); }
}
.delete-btn { .delete-btn {
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 160rpx; width: 160rpx;
background: #FF6B6B; background: $danger;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -169,9 +181,9 @@ function onTouchEnd() {
width: 28rpx; width: 28rpx;
height: 28rpx; height: 28rpx;
border-radius: 50%; border-radius: 50%;
background: #FFF0E6; background: $primary-light;
font-size: 16rpx; font-size: 16rpx;
color: #FF8C69; color: $primary;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
@@ -182,25 +194,28 @@ function onTouchEnd() {
min-width: 0; min-width: 0;
} }
.name-row {
display: flex;
align-items: center;
gap: 8rpx;
}
.name { .name {
font-size: 28rpx; font-size: 28rpx;
font-weight: 500; font-weight: 500;
color: #2D1B1B; color: $text;
display: block; @include text-ellipsis;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.meta { .meta {
font-size: 24rpx; font-size: 24rpx;
color: #8B7E7E; color: $text-sec;
display: block; display: block;
margin-top: 4rpx; margin-top: 4rpx;
} }
.creator-name { .creator-name {
color: #FF8C69; color: $primary;
font-weight: 500; font-weight: 500;
} }
@@ -211,7 +226,13 @@ function onTouchEnd() {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
&.expense { color: #2D1B1B; } &.expense { color: $text; }
&.income { color: #7BC67E; } &.income { color: $success; }
}
@media (prefers-reduced-motion: reduce) {
.show-hint .tx-item {
animation: none;
}
} }
</style> </style>

View File

@@ -0,0 +1,39 @@
/**
* 页面就绪 composable
* 统一处理 waitForReady 超时 + 数据加载错误状态
*/
import { ref, onMounted } from 'vue'
import { waitForReady } from '@/utils/app-ready'
export function usePageReady(onReady: () => Promise<void>) {
const readyError = ref(false)
const loading = ref(true)
async function init() {
try {
await waitForReady()
} catch {
readyError.value = true
loading.value = false
return
}
try {
await onReady()
} catch (e) {
console.error('[PageReady] loadData error:', e)
} finally {
loading.value = false
}
}
/** 重试(用户点击"重新加载"按钮时调用) */
async function retry() {
readyError.value = false
loading.value = true
await init()
}
onMounted(init)
return { readyError, loading, retry }
}

View File

@@ -12,7 +12,8 @@
"path": "pages/add/index", "path": "pages/add/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "记一笔" "navigationBarTitleText": "记一笔",
"enablePullDownRefresh": false
} }
}, },
{ {
@@ -47,6 +48,13 @@
"enablePullDownRefresh": true "enablePullDownRefresh": true
} }
}, },
{
"path": "pages/recurring/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "周期账单"
}
},
{ {
"path": "pages/budget/index", "path": "pages/budget/index",
"style": { "style": {
@@ -59,7 +67,8 @@
"path": "pages/profile-edit/index", "path": "pages/profile-edit/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "编辑资料" "navigationBarTitleText": "编辑资料",
"enablePullDownRefresh": false
} }
}, },
{ {
@@ -113,7 +122,8 @@
"path": "pages/feedback/index", "path": "pages/feedback/index",
"style": { "style": {
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "意见反馈" "navigationBarTitleText": "意见反馈",
"enablePullDownRefresh": false
} }
}, },
{ {
@@ -147,6 +157,29 @@
"navigationBarTitleText": "埋点统计", "navigationBarTitleText": "埋点统计",
"enablePullDownRefresh": true "enablePullDownRefresh": true
} }
},
{
"path": "pages/tag-manage/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "标签管理",
"enablePullDownRefresh": true
}
},
{
"path": "pages/backup-manage/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "数据备份",
"enablePullDownRefresh": true
}
},
{
"path": "pages/data-import/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "数据导入"
}
} }
], ],
"globalStyle": { "globalStyle": {
@@ -154,7 +187,8 @@
"navigationBarTitleText": "小菜记账", "navigationBarTitleText": "小菜记账",
"navigationBarBackgroundColor": "#FFF8F0", "navigationBarBackgroundColor": "#FFF8F0",
"backgroundColor": "#FFF8F0", "backgroundColor": "#FFF8F0",
"backgroundTextStyle": "dark" "backgroundTextStyle": "dark",
"enablePullDownRefresh": true
}, },
"tabBar": { "tabBar": {
"color": "#BFB3B3", "color": "#BFB3B3",

View File

@@ -6,12 +6,12 @@
<view class="nav-back" @tap="goBack"> <view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" /> <Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view> </view>
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text> <text class="title">{{ viewId ? '查看记录' : editId ? '编辑记录' : '记一笔' }}</text>
<view :style="{ width: capsuleRight + 88 + 'px' }"></view> <view :style="{ width: capsuleRight + 88 + 'px' }"></view>
</view> </view>
</view> </view>
<view class="type-toggle-wrap"> <view class="type-toggle-wrap" v-if="!viewId">
<view class="type-toggle" :class="{ income: type === 'income' }"> <view class="type-toggle" :class="{ income: type === 'income' }">
<view class="slider"></view> <view class="slider"></view>
<view class="tab" :class="{ active: type === 'expense' }" @tap="switchType('expense')">支出</view> <view class="tab" :class="{ active: type === 'expense' }" @tap="switchType('expense')">支出</view>
@@ -19,43 +19,113 @@
</view> </view>
</view> </view>
<view class="amount-display"> <!-- 只读模式显示收支类型标签 -->
<text class="currency">¥</text> <view class="type-toggle-wrap" v-if="viewId">
<text class="digits">{{ displayAmount }}</text> <view class="readonly-type-badge" :class="type">
<text class="cursor">|</text> <text>{{ type === 'expense' ? '支出' : '收入' }}</text>
</view>
</view> </view>
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat"> <AmountEditor
ref="amountEditorRef"
v-model="amountStr"
:fixed="true"
size="large"
:auto-focus="!viewId"
:disabled="!!viewId"
@focus="amountFocused = true"
@blur="amountFocused = false"
@confirm="save"
/>
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat" @tap="!viewId && blurAmountEditor()">
<view class="category-grid"> <view class="category-grid">
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" @tap="selectedCat = cat.id"> <view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id, 'cat-readonly': !!viewId }" @tap="!viewId && (selectedCat = cat.id)">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" /> <view class="cat-icon-wrap">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
<view v-if="suggestedCatId === cat.id && selectedCat !== cat.id" class="suggest-badge">
<text class="suggest-badge-text">推荐</text>
</view>
</view>
<text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text> <text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
<view class="extra-fields"> <view class="extra-fields">
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange"> <picker v-if="!viewId" mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
<view class="extra-row"> <view class="extra-row" @tap="blurAmountEditor">
<Icon name="calendar" :size="28" color="#8B7E7E" /> <Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="extra-text">{{ dateLabel }}</text> <text class="extra-text">{{ dateLabel }}</text>
</view> </view>
</picker> </picker>
<view v-else class="extra-row">
<Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="extra-text">{{ dateLabel }}</text>
</view>
<view class="extra-row"> <view class="extra-row">
<Icon name="edit" :size="28" color="#8B7E7E" /> <Icon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" /> <input class="extra-input" v-model="note" :disabled="!!viewId" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="!viewId && blurAmountEditor()" />
</view>
<view class="extra-row" @tap="!viewId && openTagPicker()">
<Icon name="tag" :size="28" color="#8B7E7E" />
<view class="tag-display">
<view v-if="selectedTagIds.length === 0" class="tag-placeholder">
<text class="extra-text">选择标签选填</text>
<text class="tag-help">用于跨分类标记账单便于筛选和统计</text>
</view>
<view class="tag-chips" v-else>
<view v-for="tId in selectedTagIds" :key="tId" class="tag-chip" :style="{ background: (getTagById(tId)?.color || '#FF8C69') + '20', color: getTagById(tId)?.color || '#FF8C69' }">
<text class="tag-chip-text">{{ getTagById(tId)?.name || '' }}</text>
</view>
</view>
</view>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view> </view>
</view> </view>
<Numpad v-model="amountStr" @confirm="save" /> <view v-if="!viewId && !showSuccess && !amountFocused" class="page-save-bar">
<view class="page-save-btn" :class="{ disabled: saving }" @tap="save">
<text class="page-save-text">{{ saving ? '保存中...' : (editId ? '保存修改' : '保存') }}</text>
</view>
</view>
<SaveSuccess <SaveSuccess
v-if="!viewId"
v-model:visible="showSuccess" v-model:visible="showSuccess"
:text="editId ? '修改成功' : '记账成功'" :text="editId ? '修改成功' : '记账成功'"
:showContinue="!editId" :showContinue="!editId"
@continue="onContinue" @continue="onContinue"
@back="onBack" @back="onBack"
/> />
<!-- 标签选择弹窗 -->
<view class="modal-mask" v-if="showTagPicker" @tap="showTagPicker = false">
<view class="tag-modal" @tap.stop>
<text class="tag-modal-title">选择标签</text>
<text class="tag-modal-desc">标签用于跨分类标记账单例如报销 / 家庭 / 旅行便于筛选和统计</text>
<text class="tag-modal-hint">最多选择 5 个标签</text>
<scroll-view class="tag-modal-list" scroll-y>
<view
v-for="tag in tagStore.tags"
:key="tag.id"
class="tag-option"
:class="{ selected: selectedTagIds.includes(tag.id) }"
@tap="toggleTag(tag.id)"
>
<view class="tag-option-dot" :style="{ background: tag.color }"></view>
<text class="tag-option-name">{{ tag.name }}</text>
<Icon v-if="selectedTagIds.includes(tag.id)" name="check" :size="24" color="#FF8C69" />
</view>
<view class="tag-empty" v-if="tagStore.tags.length === 0">
<text class="tag-empty-text">暂无标签可在"我的-标签管理"中添加</text>
</view>
</scroll-view>
<view class="tag-modal-btns">
<view class="tag-modal-btn" @tap="showTagPicker = false">确定</view>
</view>
</view>
</view>
</view> </view>
</template> </template>
@@ -64,8 +134,10 @@ import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { useCategoryStore } from '@/stores/category' import { useCategoryStore } from '@/stores/category'
import { useTransactionStore } from '@/stores/transaction' import { useTransactionStore } from '@/stores/transaction'
import { useTagStore } from '@/stores/tag'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import Numpad from '@/components/Numpad/Numpad.vue' import { suggestCategory } from '@/api/stats'
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue' import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
@@ -73,19 +145,28 @@ import { statusBarHeight, capsuleRight } from '@/utils/system'
const catStore = useCategoryStore() const catStore = useCategoryStore()
const txStore = useTransactionStore() const txStore = useTransactionStore()
const tagStore = useTagStore()
const type = ref<'expense' | 'income'>('expense') const type = ref<'expense' | 'income'>('expense')
const amountStr = ref('0') const amountStr = ref('0')
const selectedCat = ref<number | null>(null) const selectedCat = ref<number | null>(null)
const note = ref('') const note = ref('')
const selectedTagIds = ref<number[]>([])
const showTagPicker = ref(false)
const suggestedCatId = ref<number | null>(null)
const suggestConfidence = ref(0)
let suggestTimer: ReturnType<typeof setTimeout> | null = null
const now = new Date() const now = new Date()
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const selectedDate = ref(localToday) const selectedDate = ref(localToday)
const saving = ref(false) const saving = ref(false)
const editId = ref<number | null>(null) const editId = ref<number | null>(null)
const viewId = ref<number | null>(null)
const showSuccess = ref(false) const showSuccess = ref(false)
const scrollToCat = ref('') const scrollToCat = ref('')
const todayStr = localToday const todayStr = localToday
const amountFocused = ref(false)
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
// Remember last selected category per type // Remember last selected category per type
const lastCatByType: Record<string, number | null> = { expense: null, income: null } const lastCatByType: Record<string, number | null> = { expense: null, income: null }
@@ -94,10 +175,6 @@ const dateLabel = computed(() => {
return `${d.getMonth() + 1}${d.getDate()}` return `${d.getMonth() + 1}${d.getDate()}`
}) })
const displayAmount = computed(() => {
if (!amountStr.value) return ''
return amountStr.value
})
const currentCategories = computed(() => catStore.getByType(type.value)) const currentCategories = computed(() => catStore.getByType(type.value))
@@ -121,8 +198,43 @@ watch(currentCategories, (cats) => {
} }
}) })
// 智能分类推荐:监听金额和备注变化,防抖 600ms 后请求建议
watch([amountStr, note], () => {
if (suggestTimer) clearTimeout(suggestTimer)
suggestTimer = setTimeout(async () => {
const amt = Math.round(parseFloat(amountStr.value || '0') * 100)
if (amt <= 0 || editId.value || viewId.value) {
suggestedCatId.value = null
suggestConfidence.value = 0
return
}
try {
const result = await suggestCategory(amt, type.value, note.value)
if (result.category && result.confidence >= 30) {
suggestedCatId.value = result.category.id
suggestConfidence.value = result.confidence
// 置信度 >= 60 时自动选中
if (result.confidence >= 60 && !editId.value && !viewId.value) {
selectedCat.value = result.category.id
}
} else {
suggestedCatId.value = null
suggestConfidence.value = 0
}
} catch {
suggestedCatId.value = null
}
}, 600)
})
onMounted(async () => { onMounted(async () => {
await waitForReady() try {
await waitForReady()
} catch {
uni.showToast({ title: '网络异常,请重试', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1200)
return
}
// 加载分类列表,失败时重试一次 // 加载分类列表,失败时重试一次
try { try {
@@ -135,8 +247,15 @@ onMounted(async () => {
} }
} }
// 加载标签列表
try {
await tagStore.fetchTags()
} catch {
// 标签加载失败不影响主流程
}
const pages = getCurrentPages() const pages = getCurrentPages()
const page = pages[pages.length - 1] as any const page = pages[pages.length - 1] as { options?: Record<string, string> }
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) { if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {
type.value = page.options.type type.value = page.options.type
} }
@@ -154,7 +273,32 @@ onMounted(async () => {
lastCatByType[existing.type] = existing.category_id lastCatByType[existing.type] = existing.category_id
note.value = existing.note || '' note.value = existing.note || ''
selectedDate.value = existing.date.slice(0, 10) selectedDate.value = existing.date.slice(0, 10)
selectedTagIds.value = existing.tags?.map(t => t.id) || []
// 等待分类列表渲染完成后滚动到选中项 // 等待分类列表渲染完成后滚动到选中项
nextTick(() => {
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
amountEditorRef.value?.focus()
})
} else {
uni.showToast({ title: '记录不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
}
}
// 只读查看模式:群组中非本人记录
if (page?.options?.viewId) {
viewId.value = Number(page.options.viewId)
let existing = txStore.transactions.find(t => t.id === viewId.value)
if (!existing) {
existing = await txStore.fetchTransactionById(viewId.value!)
}
if (existing) {
type.value = existing.type
amountStr.value = (existing.amount / 100).toFixed(2)
selectedCat.value = existing.category_id
lastCatByType[existing.type] = existing.category_id
note.value = existing.note || ''
selectedDate.value = existing.date.slice(0, 10)
selectedTagIds.value = existing.tags?.map(t => t.id) || []
nextTick(() => { nextTick(() => {
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200) setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
}) })
@@ -172,15 +316,22 @@ onMounted(async () => {
// 返回页面时刷新分类(可能新增了分类) // 返回页面时刷新分类(可能新增了分类)
const initialLoaded = ref(false) const initialLoaded = ref(false)
onShow(() => { onShow(() => {
if (initialLoaded.value) catStore.fetchCategories() if (initialLoaded.value) {
catStore.fetchCategories()
tagStore.fetchTags()
}
}) })
function onDateChange(e: any) { function onDateChange(e: any) {
selectedDate.value = e.detail.value selectedDate.value = e.detail.value
} }
function blurAmountEditor() {
amountEditorRef.value?.blur()
}
async function save() { async function save() {
if (saving.value) return if (saving.value || viewId.value) return
const amount = Math.round(parseFloat(amountStr.value || '0') * 100) const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
if (isNaN(amount) || amount <= 0) { if (isNaN(amount) || amount <= 0) {
uni.showToast({ title: '请输入金额', icon: 'none' }) uni.showToast({ title: '请输入金额', icon: 'none' })
@@ -198,7 +349,8 @@ async function save() {
type: type.value, type: type.value,
category_id: selectedCat.value, category_id: selectedCat.value,
note: note.value, note: note.value,
date: selectedDate.value date: selectedDate.value,
...(selectedTagIds.value.length > 0 ? { tagIds: selectedTagIds.value } : {})
} }
if (editId.value) { if (editId.value) {
await txStore.updateTransaction(editId.value, data) await txStore.updateTransaction(editId.value, data)
@@ -222,7 +374,9 @@ function onContinue() {
// 重置表单,保留分类和日期 // 重置表单,保留分类和日期
amountStr.value = '' amountStr.value = ''
note.value = '' note.value = ''
selectedTagIds.value = []
showSuccess.value = false showSuccess.value = false
nextTick(() => amountEditorRef.value?.focus())
} }
/** 返回上一页 */ /** 返回上一页 */
@@ -230,9 +384,36 @@ function onBack() {
uni.navigateBack() uni.navigateBack()
} }
function openTagPicker() {
blurAmountEditor()
showTagPicker.value = true
}
function toggleTag(tagId: number) {
const idx = selectedTagIds.value.indexOf(tagId)
if (idx >= 0) {
selectedTagIds.value = selectedTagIds.value.filter(id => id !== tagId)
} else {
if (selectedTagIds.value.length >= 5) {
uni.showToast({ title: '最多选择 5 个标签', icon: 'none' })
return
}
selectedTagIds.value = [...selectedTagIds.value, tagId]
}
}
function getTagById(id: number) {
return tagStore.getById(id)
}
function goBack() { function goBack() {
// 保存成功后直接返回,不弹确认框 // 保存成功后直接返回,不弹确认框
if (showSuccess.value) return if (showSuccess.value) return
// 只读查看模式不应提示放弃编辑
if (viewId.value) {
uni.navigateBack()
return
}
const hasInput = amountStr.value !== '0' || note.value const hasInput = amountStr.value !== '0' || note.value
if (hasInput) { if (hasInput) {
uni.showModal({ uni.showModal({
@@ -252,10 +433,39 @@ function goBack() {
@import '@/styles/mixins.scss'; @import '@/styles/mixins.scss';
.page { .page {
@include page-base; min-height: 100vh;
background: $bg;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: 0; // AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
$numpad-h: 464rpx;
padding-bottom: calc(#{$numpad-h} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$numpad-h} + env(safe-area-inset-bottom));
}
.page-save-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
padding: $space-md $space-xl calc($space-md + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(255, 250, 247, 0), $bg 28%);
}
.page-save-btn {
@include btn-primary;
width: 100%;
&.disabled {
opacity: 0.7;
}
}
.page-save-text {
color: $surface;
font-size: $font-lg;
font-weight: 600;
} }
.header-fixed { .header-fixed {
@@ -280,7 +490,11 @@ function goBack() {
.title { font-size: $font-xl; font-weight: 500; color: $text; } .title { font-size: $font-xl; font-weight: 500; color: $text; }
.type-toggle-wrap { display: flex; justify-content: center; margin: $space-sm 0; } .type-toggle-wrap {
display: flex; justify-content: center;
margin: $space-sm 0;
@include fade-in-up;
}
.type-toggle { .type-toggle {
display: inline-flex; display: inline-flex;
@@ -315,43 +529,11 @@ function goBack() {
background: $surface; background: $surface;
border-radius: $radius-lg; border-radius: $radius-lg;
box-shadow: $shadow-sm; box-shadow: $shadow-sm;
transition: transform $transition-normal; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.income .slider { transform: translateX(160rpx); } .income .slider { transform: translateX(160rpx); }
.amount-display {
text-align: center;
padding: $space-lg $space-xl $space-md;
}
.currency {
font-family: 'Fredoka', sans-serif;
font-size: $space-2xl;
font-weight: 500;
color: $text-muted;
margin-right: $space-xs;
}
.digits {
font-family: 'Fredoka', sans-serif;
font-size: 88rpx;
font-weight: 700;
color: $text;
font-variant-numeric: tabular-nums;
}
.cursor {
font-family: 'Fredoka', monospace;
font-size: 88rpx;
font-weight: 300;
color: $primary;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.category-scroll { .category-scroll {
max-height: 320rpx; max-height: 320rpx;
@@ -370,8 +552,45 @@ function goBack() {
align-items: center; align-items: center;
gap: 6rpx; gap: 6rpx;
padding: 12rpx 0; padding: 12rpx 0;
position: relative;
-webkit-tap-highlight-color: transparent;
&:active { transform: scale(0.95); } &:active { transform: scale(0.92); }
&.suggested {
.cat-icon-wrap {
border: 3rpx dashed $primary;
border-radius: $radius-lg;
padding: 6rpx;
margin: -6rpx;
animation: suggest-pulse 2s ease-in-out infinite;
}
}
}
@keyframes suggest-pulse {
0%, 100% { border-color: $primary; }
50% { border-color: lighten($primary, 30%); }
}
.cat-icon-wrap {
position: relative;
transition: all $transition-normal;
}
.suggest-badge {
position: absolute;
top: -8rpx;
right: -12rpx;
padding: 2rpx 10rpx;
background: $primary;
border-radius: $radius-xs;
}
.suggest-badge-text {
font-size: 18rpx;
color: $surface;
font-weight: 600;
} }
.cat-name { font-size: $font-sm; color: $text-sec; } .cat-name { font-size: $font-sm; color: $text-sec; }
@@ -404,10 +623,142 @@ function goBack() {
.placeholder { color: $text-muted; } .placeholder { color: $text-muted; }
@media (prefers-reduced-motion: reduce) { .tag-display {
.cursor { flex: 1;
animation: none; display: flex;
opacity: 1; align-items: center;
min-width: 0;
}
.tag-placeholder {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
min-width: 0;
}
.tag-help {
font-size: $font-xs;
color: $text-muted;
}
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.tag-chip {
padding: 4rpx 16rpx;
border-radius: $radius-xs;
}
.tag-chip-text {
font-size: $font-sm;
font-weight: 500;
}
/* 标签选择弹窗 */
.modal-mask {
@include modal-mask;
}
.tag-modal {
@include bottom-modal;
padding-bottom: calc($space-xl + env(safe-area-inset-bottom));
}
.tag-modal-title {
@include modal-title;
text-align: left;
padding: $space-lg $space-xl $space-xs;
}
.tag-modal-desc {
font-size: $font-sm;
color: $text-sec;
line-height: 1.5;
padding: 0 $space-xl $space-xs;
}
.tag-modal-hint {
font-size: $font-sm;
color: $text-muted;
padding: 0 $space-xl $space-md;
}
.tag-modal-list {
max-height: 500rpx;
padding: 0 $space-xl;
}
.tag-option {
display: flex;
align-items: center;
gap: $space-sm;
padding: $space-md 0;
border-bottom: 1rpx solid $border;
&:last-child { border-bottom: none; }
&:active { background: $surface-warm; }
}
.tag-option-dot {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
flex-shrink: 0;
}
.tag-option-name {
flex: 1;
font-size: $font-lg;
color: $text;
}
.tag-empty {
padding: $space-2xl 0;
text-align: center;
}
.tag-empty-text {
font-size: $font-md;
color: $text-muted;
}
.tag-modal-btns {
padding: $space-md $space-xl;
border-top: 2rpx solid $border;
}
.tag-modal-btn {
@include btn-primary;
width: 100%;
}
/* 只读模式样式 */
.readonly-type-badge {
display: inline-flex;
padding: $space-xs $space-lg;
border-radius: $radius-xl;
font-size: $font-lg;
font-weight: 500;
&.expense {
background: rgba(255, 107, 107, 0.1);
color: #FF6B6B;
}
&.income {
background: rgba(123, 198, 126, 0.1);
color: #7BC67E;
} }
} }
.cat-readonly {
pointer-events: none;
opacity: 0.7;
}
</style> </style>

View File

@@ -69,7 +69,7 @@ const form = reactive({
}) })
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
try { try {
await loadConfig() await loadConfig()
} catch (e: any) { } catch (e: any) {

View File

@@ -52,13 +52,13 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<view class="actions"> <view class="actions">
<view v-if="item.status === 'pending'" class="action-btn process" @tap="updateStatus(item, 'processed')"> <view v-if="item.status === 'pending'" class="action-btn process" @tap.stop="openReplyModal(item)">
<text class="action-text">标记处理</text> <text class="action-text">标记处理</text>
</view> </view>
<view v-if="item.status === 'pending'" class="action-btn ignore" @tap="updateStatus(item, 'ignored')"> <view v-if="item.status === 'pending'" class="action-btn ignore" @tap.stop="openReplyModal(item, 'ignored')">
<text class="action-text">忽略</text> <text class="action-text">忽略</text>
</view> </view>
<view v-if="item.status !== 'pending'" class="action-btn pending" @tap="updateStatus(item, 'pending')"> <view v-if="item.status !== 'pending'" class="action-btn pending" @tap.stop="updateStatus(item, 'pending')">
<text class="action-text">重新打开</text> <text class="action-text">重新打开</text>
</view> </view>
</view> </view>
@@ -77,6 +77,39 @@
<text class="empty-text">暂无反馈</text> <text class="empty-text">暂无反馈</text>
</view> </view>
</scroll-view> </scroll-view>
<!-- 回复弹窗 -->
<view class="modal-mask" v-if="showReplyModal" @tap="showReplyModal = false">
<view class="reply-modal" @tap.stop>
<view class="modal-header">
<text class="modal-title">{{ replyAction === 'processed' ? '标记处理' : '忽略反馈' }}</text>
<view class="modal-close" @tap="showReplyModal = false">
<text class="modal-close-text">×</text>
</view>
</view>
<view class="reply-body">
<view class="reply-origin" v-if="replyTarget">
<text class="reply-origin-label">用户反馈</text>
<text class="reply-origin-text">{{ replyTarget.content }}</text>
</view>
<text class="reply-label">回复内容可选</text>
<textarea
class="reply-textarea"
v-model="replyText"
placeholder="输入回复内容,将通知给用户..."
maxlength="500"
/>
</view>
<view class="reply-footer">
<view class="reply-btn skip" @tap="handleSkipReply">
<text class="reply-btn-text">{{ replyAction === 'processed' ? '仅标记处理' : '仅忽略' }}</text>
</view>
<view class="reply-btn confirm" @tap="handleWithReply">
<text class="reply-btn-text">{{ replyAction === 'processed' ? '回复并处理' : '回复并忽略' }}</text>
</view>
</view>
</view>
</view>
</view> </view>
</template> </template>
@@ -86,7 +119,8 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback' import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback'
import { API_BASE } from '@/config' import type { Feedback } from '@/api/feedback'
import { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
@@ -102,7 +136,7 @@ const statusMap: Record<string, string> = {
ignored: '已忽略' ignored: '已忽略'
} }
const list = ref<any[]>([]) const list = ref<Feedback[]>([])
const loading = ref(false) const loading = ref(false)
const filterStatus = ref('') const filterStatus = ref('')
const page = ref(1) const page = ref(1)
@@ -110,10 +144,16 @@ const pageSize = 20
const total = ref(0) const total = ref(0)
const initialLoaded = ref(false) const initialLoaded = ref(false)
// 回复弹窗
const showReplyModal = ref(false)
const replyTarget = ref<Feedback | null>(null)
const replyAction = ref<'processed' | 'ignored'>('processed')
const replyText = ref('')
const noMore = computed(() => list.value.length >= total.value && total.value > 0) const noMore = computed(() => list.value.length >= total.value && total.value > 0)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
loadList(true).finally(() => { initialLoaded.value = true }) loadList(true).finally(() => { initialLoaded.value = true })
}) })
@@ -138,7 +178,7 @@ async function loadList(reset = false) {
} }
loading.value = true loading.value = true
try { try {
const params: any = { page: page.value, pageSize } const params: { page: number; pageSize: number; status?: string } = { page: page.value, pageSize }
if (filterStatus.value) params.status = filterStatus.value if (filterStatus.value) params.status = filterStatus.value
const data = await getFeedbackList(params) const data = await getFeedbackList(params)
if (reset) { if (reset) {
@@ -160,7 +200,35 @@ function loadMore() {
loadList(false) loadList(false)
} }
async function updateStatus(item: any, status: string) { function openReplyModal(item: Feedback, action: 'processed' | 'ignored' = 'processed') {
replyTarget.value = item
replyAction.value = action
replyText.value = ''
showReplyModal.value = true
}
async function handleSkipReply() {
if (!replyTarget.value) return
await doUpdateStatus(replyTarget.value, replyAction.value)
}
async function handleWithReply() {
if (!replyTarget.value) return
await doUpdateStatus(replyTarget.value, replyAction.value, replyText.value.trim() || undefined)
}
async function doUpdateStatus(item: Feedback, status: Feedback['status'], admin_reply?: string) {
try {
await updateFeedbackStatus(item.id, status, admin_reply)
item.status = status
showReplyModal.value = false
uni.showToast({ title: admin_reply ? '已回复' : '已更新', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
async function updateStatus(item: Feedback, status: Feedback['status']) {
try { try {
await updateFeedbackStatus(item.id, status) await updateFeedbackStatus(item.id, status)
item.status = status item.status = status
@@ -177,12 +245,6 @@ function copyContact(contact: string) {
}) })
} }
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
function formatTime(dateStr: string): string { function formatTime(dateStr: string): string {
const d = new Date(dateStr) const d = new Date(dateStr)
const now = new Date() const now = new Date()
@@ -420,4 +482,128 @@ function goBack() { uni.navigateBack() }
font-size: $font-md; font-size: $font-md;
color: $text-muted; color: $text-muted;
} }
/* 回复弹窗 */
.reply-modal {
width: 100%;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
padding-bottom: calc($space-xl + env(safe-area-inset-bottom));
margin-top: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.modal-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $border;
&:active { background: darken($border, 5%); }
}
.modal-close-text {
font-size: $font-2xl;
color: $text-sec;
line-height: 1;
}
.reply-body {
padding: $space-lg $space-xl;
}
.reply-origin {
padding: $space-md;
background: $bg;
border-radius: $radius-lg;
margin-bottom: $space-lg;
}
.reply-origin-label {
font-size: $font-sm;
color: $text-muted;
display: block;
margin-bottom: $space-xs;
}
.reply-origin-text {
font-size: $font-md;
color: $text-sec;
line-height: 1.6;
}
.reply-label {
font-size: $font-base;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-sm;
}
.reply-textarea {
width: 100%;
height: 200rpx;
padding: $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-md;
color: $text;
box-sizing: border-box;
}
.reply-footer {
display: flex;
gap: $space-md;
padding: $space-md $space-xl;
border-top: 2rpx solid $border;
}
.reply-btn {
flex: 1;
height: 80rpx;
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
&.skip {
background: $bg;
border: 2rpx solid $border;
}
&.confirm {
background: linear-gradient(135deg, $primary, #E67355);
}
&:active { opacity: 0.8; }
}
.reply-btn-text {
font-size: $font-md;
font-weight: 600;
.skip & { color: $text-sec; }
.confirm & { color: $surface; }
}
.modal-mask {
@include modal-mask;
}
</style> </style>

View File

@@ -59,6 +59,29 @@
</view> </view>
</view> </view>
<!-- 健康状态 -->
<view class="health-card" v-if="health">
<view class="health-header">
<text class="health-title">系统状态</text>
<view class="health-badge" :class="health.status">
<view class="health-dot"></view>
<text class="health-badge-text">{{ health.status === 'ok' ? '正常' : health.status === 'degraded' ? '降级' : '异常' }}</text>
</view>
</view>
<view class="detail-row">
<text class="detail-label">数据库</text>
<text class="detail-value" :class="health.db === 'connected' ? 'income' : 'expense'">{{ health.db === 'connected' ? '已连接' : '断开' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">运行时间</text>
<text class="detail-value">{{ formatUptime(health.uptime) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">版本</text>
<text class="detail-value">{{ health.version }}</text>
</view>
</view>
<!-- 功能入口 --> <!-- 功能入口 -->
<view class="section-title">管理功能</view> <view class="section-title">管理功能</view>
<view class="entry-list"> <view class="entry-list">
@@ -103,42 +126,40 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { getDashboard } from '@/api/admin' import { getDashboard } from '@/api/admin'
import { getHealth } from '@/api/health'
import type { HealthStatus } from '@/api/health'
import { formatAmount } from '@/utils/format' import { formatAmount } from '@/utils/format'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import type { Dashboard } from '@/api/admin' import type { Dashboard } from '@/api/admin'
const loading = ref(true) const loading = ref(true)
const dashboard = ref<Dashboard | null>(null) const dashboard = ref<Dashboard | null>(null)
const health = ref<HealthStatus | null>(null)
// 仪表盘缓存5分钟内不重复请求
let dashboardCache: { data: Dashboard; time: number } | null = null
const CACHE_TTL = 5 * 60 * 1000
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
try { try {
// 检查缓存是否有效 dashboard.value = await getDashboard()
if (dashboardCache && Date.now() - dashboardCache.time < CACHE_TTL) {
dashboard.value = dashboardCache.data
} else {
dashboard.value = await getDashboard()
dashboardCache = { data: dashboard.value, time: Date.now() }
}
} catch (e: any) { } catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' }) uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally { } finally {
loading.value = false loading.value = false
} }
// 加载健康状态(不阻塞页面)
try {
health.value = await getHealth()
} catch {
// 健康检查失败不影响主流程
}
}) })
// 静默刷新(强制刷新缓存) // 静默刷新
async function refreshDashboard(force = false) { async function refreshDashboard() {
try { try {
if (force || !dashboardCache || Date.now() - dashboardCache.time >= CACHE_TTL) { dashboard.value = await getDashboard()
dashboard.value = await getDashboard() health.value = await getHealth()
dashboardCache = { data: dashboard.value, time: Date.now() }
}
} catch {} } catch {}
} }
@@ -148,7 +169,7 @@ onShow(() => {
}) })
onPullDownRefresh(async () => { onPullDownRefresh(async () => {
await refreshDashboard(true) await refreshDashboard()
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
}) })
@@ -159,6 +180,16 @@ function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) }
function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) } function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
function goLogs() { uni.navigateTo({ url: '/pages/admin/logs' }) } function goLogs() { uni.navigateTo({ url: '/pages/admin/logs' }) }
function goTrack() { uni.navigateTo({ url: '/pages/admin/track' }) } function goTrack() { uni.navigateTo({ url: '/pages/admin/track' }) }
function formatUptime(seconds: number): string {
if (!seconds) return '-'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}${hours}小时`
if (hours > 0) return `${hours}小时${mins}`
return `${mins}分钟`
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -312,4 +343,58 @@ function goTrack() { uni.navigateTo({ url: '/pages/admin/track' }) }
font-weight: 500; font-weight: 500;
color: $text; color: $text;
} }
/* 健康状态卡片 */
.health-card {
background: $surface;
border-radius: $radius-xl;
padding: $space-lg;
border: 2rpx solid $border;
box-shadow: $shadow-md;
margin-bottom: $space-lg;
}
.health-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $space-md;
}
.health-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
}
.health-badge {
display: flex;
align-items: center;
gap: $space-xs;
padding: 4rpx 16rpx;
border-radius: $radius-lg;
&.ok { background: rgba(123, 198, 126, 0.1); }
&.degraded { background: rgba(255, 215, 0, 0.1); }
&.error { background: rgba(255, 107, 107, 0.1); }
}
.health-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
.ok & { background: $success; }
.degraded & { background: #FFD700; }
.error & { background: $danger; }
}
.health-badge-text {
font-size: $font-sm;
font-weight: 500;
.ok & { color: $success; }
.degraded & { color: #CC9900; }
.error & { color: $danger; }
}
</style> </style>

View File

@@ -69,7 +69,12 @@
<view class="filter-tab" :class="{ active: filterLevel === 'warn' }" @tap="filterLevel = 'warn'">警告</view> <view class="filter-tab" :class="{ active: filterLevel === 'warn' }" @tap="filterLevel = 'warn'">警告</view>
<view class="filter-tab" :class="{ active: filterLevel === 'info' }" @tap="filterLevel = 'info'">信息</view> <view class="filter-tab" :class="{ active: filterLevel === 'info' }" @tap="filterLevel = 'info'">信息</view>
</view> </view>
<input class="search-input" v-model="keyword" placeholder="搜索关键词" @confirm="loadLogs" /> <view class="search-wrap">
<input class="search-input" v-model="keyword" placeholder="搜索关键词" />
<view class="search-btn" @tap="loadLogs">
<text class="search-btn-text">搜索</text>
</view>
</view>
</view> </view>
<!-- 日志列表 --> <!-- 日志列表 -->
@@ -120,7 +125,7 @@ function getTodayStr(): string {
} }
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await Promise.all([loadStats(), loadLogs()]) await Promise.all([loadStats(), loadLogs()])
}) })
@@ -175,14 +180,12 @@ function loadMore() {
function selectDate(date: string) { function selectDate(date: string) {
selectedDate.value = date selectedDate.value = date
page.value = 1 // loadLogs() 由 watch([filterLevel, selectedDate]) 统一触发,避免双重请求
loadLogs()
} }
function onDateChange(e: any) { function onDateChange(e: any) {
selectedDate.value = e.detail.value selectedDate.value = e.detail.value
page.value = 1 // loadLogs() 由 watch 统一触发
loadLogs()
} }
function getBarWidth(count: number): number { function getBarWidth(count: number): number {
@@ -382,6 +385,40 @@ function goBack() {
margin-bottom: $space-md; margin-bottom: $space-md;
} }
.search-wrap {
flex: 1;
display: flex;
gap: $space-xs;
}
.search-input {
flex: 1;
height: 64rpx;
padding: 0 $space-md;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-md;
}
.search-btn {
height: 64rpx;
padding: 0 $space-md;
background: $primary;
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.8; }
}
.search-btn-text {
font-size: $font-md;
color: $surface;
font-weight: 500;
}
.filter-tabs { .filter-tabs {
display: flex; display: flex;
gap: $space-xs; gap: $space-xs;
@@ -403,16 +440,6 @@ function goBack() {
} }
} }
.search-input {
flex: 1;
height: 64rpx;
padding: 0 $space-md;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-md;
}
.log-list { .log-list {
height: calc(100vh - 700rpx); height: calc(100vh - 700rpx);
background: $surface; background: $surface;

View File

@@ -197,7 +197,7 @@ const defaultForm = {
const form = ref({ ...defaultForm }) const form = ref({ ...defaultForm })
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadNotifications(true) await loadNotifications(true)
}) })
@@ -586,6 +586,7 @@ onReachBottom(() => {
border-radius: $radius-2xl $radius-2xl 0 0; border-radius: $radius-2xl $radius-2xl 0 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
} }
.modal-header { .modal-header {

View File

@@ -19,7 +19,9 @@
</view> </view>
<view v-else class="event-list"> <view v-else class="event-list">
<view v-for="item in stats.today" :key="item.event" class="event-item"> <view v-for="item in stats.today" :key="item.event" class="event-item">
<text class="event-name">{{ item.event }}</text> <view class="event-tag" :class="getEventClass(item.event)">
<text class="event-tag-text">{{ getEventName(item.event) }}</text>
</view>
<text class="event-count">{{ item.count }}</text> <text class="event-count">{{ item.count }}</text>
</view> </view>
</view> </view>
@@ -44,7 +46,9 @@
<text class="card-title">热门事件7</text> <text class="card-title">热门事件7</text>
<view class="event-list"> <view class="event-list">
<view v-for="item in stats.events" :key="item.event" class="event-item" @tap="filterByEvent(item.event)"> <view v-for="item in stats.events" :key="item.event" class="event-item" @tap="filterByEvent(item.event)">
<text class="event-name">{{ item.event }}</text> <view class="event-tag" :class="getEventClass(item.event)">
<text class="event-tag-text">{{ getEventName(item.event) }}</text>
</view>
<view class="event-bar"> <view class="event-bar">
<view class="bar-fill" :style="{ width: getEventBarWidth(item.count) + '%' }"></view> <view class="bar-fill" :style="{ width: getEventBarWidth(item.count) + '%' }"></view>
</view> </view>
@@ -57,15 +61,35 @@
<view class="query-section"> <view class="query-section">
<view class="query-header"> <view class="query-header">
<text class="section-title">事件记录</text> <text class="section-title">事件记录</text>
<scroll-view class="filter-tabs" scroll-x> <scroll-view scroll-x>
<view class="filter-tab" :class="{ active: !filterEvent }" @tap="filterEvent = ''">全部</view> <view class="filter-tabs">
<view class="filter-tab" :class="{ active: filterEvent === 'page_view' }" @tap="filterEvent = 'page_view'">页面</view> <view class="filter-tab" :class="{ active: !filterEvent }" @tap="filterEvent = ''">全部</view>
<view class="filter-tab" :class="{ active: filterEvent === 'action' }" @tap="filterEvent = 'action'">操作</view> <view class="filter-tab" :class="{ active: filterEvent === 'page_view' }" @tap="filterEvent = 'page_view'">
<view class="filter-tab" :class="{ active: filterEvent === 'error' }" @tap="filterEvent = 'error'">错误</view> 页面</view>
<view class="filter-tab" :class="{ active: filterEvent === 'api_error' }" @tap="filterEvent = 'api_error'">API错误</view> <view class="filter-tab" :class="{ active: filterEvent === 'action' }" @tap="filterEvent = 'action'">操作
<view class="filter-tab" :class="{ active: filterEvent === 'app_launch' }" @tap="filterEvent = 'app_launch'">启动</view> </view>
<view class="filter-tab" :class="{ active: filterEvent === 'error' }" @tap="filterEvent = 'error'">错误</view>
<view class="filter-tab" :class="{ active: filterEvent === 'api_error' }" @tap="filterEvent = 'api_error'">
API错误</view>
<view class="filter-tab" :class="{ active: filterEvent === 'app_launch' }"
@tap="filterEvent = 'app_launch'">启动</view>
</view>
</scroll-view> </scroll-view>
</view> </view>
<view class="filter-row">
<picker mode="date" :value="filterDate" @change="onDateChange">
<view class="date-picker">
<Icon name="calendar" :size="24" color="#8B7E7E" />
<text class="date-text">{{ filterDate || '选择日期' }}</text>
</view>
</picker>
<view class="search-wrap">
<input class="search-input" v-model="filterKeyword" placeholder="搜索关键词" />
<view class="search-btn" @tap="loadEvents()">
<text class="search-btn-text">搜索</text>
</view>
</view>
</view>
<scroll-view class="event-record-list" scroll-y @scrolltolower="loadMore"> <scroll-view class="event-record-list" scroll-y @scrolltolower="loadMore">
<view v-for="item in eventList" :key="item.id" class="record-item"> <view v-for="item in eventList" :key="item.id" class="record-item">
@@ -77,7 +101,8 @@
</view> </view>
<text class="record-page" v-if="item.page">{{ item.page }}</text> <text class="record-page" v-if="item.page">{{ item.page }}</text>
<text class="record-user" v-if="item.nickname">{{ item.nickname }}</text> <text class="record-user" v-if="item.nickname">{{ item.nickname }}</text>
<text class="record-data" v-if="item.data && Object.keys(item.data).length > 0">{{ JSON.stringify(item.data) }}</text> <text class="record-data" v-if="item.data && Object.keys(item.data).length > 0">{{ JSON.stringify(item.data)
}}</text>
</view> </view>
<view v-if="loading" class="loading-box"> <view v-if="loading" class="loading-box">
@@ -107,6 +132,8 @@ import type { TrackStats, TrackEvent } from '@/api/logs'
const stats = ref<TrackStats | null>(null) const stats = ref<TrackStats | null>(null)
const filterEvent = ref('') const filterEvent = ref('')
const filterDate = ref('')
const filterKeyword = ref('')
const eventList = ref<TrackEvent[]>([]) const eventList = ref<TrackEvent[]>([])
const loading = ref(false) const loading = ref(false)
const page = ref(1) const page = ref(1)
@@ -115,7 +142,7 @@ const total = ref(0)
const noMore = computed(() => eventList.value.length >= total.value && total.value > 0) const noMore = computed(() => eventList.value.length >= total.value && total.value > 0)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await Promise.all([loadStats(), loadEvents()]) await Promise.all([loadStats(), loadEvents()])
}) })
@@ -131,7 +158,7 @@ onPullDownRefresh(async () => {
async function loadStats() { async function loadStats() {
try { try {
stats.value = await getTrackStats() stats.value = await getTrackStats()
} catch {} } catch { }
} }
async function loadEvents(append = false) { async function loadEvents(append = false) {
@@ -170,6 +197,11 @@ function filterByEvent(event: string) {
filterEvent.value = event filterEvent.value = event
} }
function onDateChange(e: any) {
filterDate.value = e.detail.value
loadEvents()
}
function getBarWidth(count: number): number { function getBarWidth(count: number): number {
if (!stats.value || stats.value.daily.length === 0) return 0 if (!stats.value || stats.value.daily.length === 0) return 0
const max = Math.max(...stats.value.daily.map(d => d.count), 1) const max = Math.max(...stats.value.daily.map(d => d.count), 1)
@@ -188,6 +220,18 @@ function getEventClass(event: string): string {
return 'page' return 'page'
} }
/** 获取事件类型中文名称 */
function getEventName(event: string): string {
const nameMap: Record<string, string> = {
page_view: '页面访问',
action: '用户操作',
error: '错误',
api_error: 'API错误',
app_launch: '应用启动'
}
return nameMap[event] || event
}
/** 格式化日期(处理 MySQL DATE 类型的 ISO 格式) */ /** 格式化日期(处理 MySQL DATE 类型的 ISO 格式) */
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
if (!dateStr) return '' if (!dateStr) return ''
@@ -237,7 +281,9 @@ function goBack() {
@include sticky-header; @include sticky-header;
} }
.status-bar { @include status-bar; } .status-bar {
@include status-bar;
}
.nav-bar { .nav-bar {
display: flex; display: flex;
@@ -245,7 +291,9 @@ function goBack() {
padding: $space-sm $space-xl $space-md; padding: $space-sm $space-xl $space-md;
} }
.nav-back { @include nav-back; } .nav-back {
@include nav-back;
}
.nav-title { .nav-title {
flex: 1; flex: 1;
@@ -255,7 +303,9 @@ function goBack() {
color: $text; color: $text;
} }
.nav-placeholder { width: 88rpx; } .nav-placeholder {
width: 88rpx;
}
.stats-card { .stats-card {
margin: $space-md $space-xl; margin: $space-md $space-xl;
@@ -296,15 +346,44 @@ function goBack() {
gap: $space-sm; gap: $space-sm;
padding: $space-xs 0; padding: $space-xs 0;
&:active { opacity: 0.7; } &:active {
opacity: 0.7;
}
} }
.event-name { .event-tag {
width: 160rpx; padding: 2rpx 12rpx;
font-size: $font-md; border-radius: $radius-xs;
color: $text;
font-weight: 500;
flex-shrink: 0; flex-shrink: 0;
&.page {
background: #E3F2FD;
}
&.action {
background: #E8F5E9;
}
&.error {
background: #FFEBEE;
}
}
.event-tag-text {
font-size: $font-sm;
font-weight: 500;
.page & {
color: #1976D2;
}
.action & {
color: #388E3C;
}
.error & {
color: #D32F2F;
}
} }
.event-bar { .event-bar {
@@ -375,6 +454,66 @@ function goBack() {
margin-bottom: $space-md; margin-bottom: $space-md;
} }
.filter-row {
display: flex;
gap: $space-sm;
margin-bottom: $space-md;
}
.date-picker {
display: flex;
align-items: center;
gap: $space-xs;
padding: $space-xs $space-md;
height: 64rpx;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
flex-shrink: 0;
}
.date-text {
font-size: $font-sm;
color: $text;
white-space: nowrap;
}
.search-wrap {
flex: 1;
display: flex;
gap: $space-xs;
}
.search-input {
flex: 1;
height: 64rpx;
padding: 0 $space-md;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-md;
}
.search-btn {
height: 64rpx;
padding: 0 $space-md;
background: $primary;
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.8;
}
}
.search-btn-text {
font-size: $font-sm;
color: $surface;
font-weight: 500;
}
.section-title { .section-title {
font-size: $font-lg; font-size: $font-lg;
font-weight: 600; font-weight: 600;
@@ -385,7 +524,6 @@ function goBack() {
display: flex; display: flex;
gap: $space-xs; gap: $space-xs;
white-space: nowrap; white-space: nowrap;
width: 100%;
} }
.filter-tab { .filter-tab {
@@ -414,7 +552,9 @@ function goBack() {
padding: $space-md; padding: $space-md;
border-bottom: 1rpx solid $border; border-bottom: 1rpx solid $border;
&:last-child { border-bottom: none; } &:last-child {
border-bottom: none;
}
} }
.record-header { .record-header {
@@ -428,18 +568,34 @@ function goBack() {
padding: 2rpx 12rpx; padding: 2rpx 12rpx;
border-radius: $radius-xs; border-radius: $radius-xs;
&.page { background: #E3F2FD; } &.page {
&.action { background: #E8F5E9; } background: #E3F2FD;
&.error { background: #FFEBEE; } }
&.action {
background: #E8F5E9;
}
&.error {
background: #FFEBEE;
}
} }
.record-event-text { .record-event-text {
font-size: $font-xs; font-size: $font-xs;
font-weight: 500; font-weight: 500;
.page & { color: #1976D2; } .page & {
.action & { color: #388E3C; } color: #1976D2;
.error & { color: #D32F2F; } }
.action & {
color: #388E3C;
}
.error & {
color: #D32F2F;
}
} }
.record-time { .record-time {
@@ -472,7 +628,8 @@ function goBack() {
border-radius: $radius-sm; border-radius: $radius-sm;
} }
.loading-box, .empty-box { .loading-box,
.empty-box {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -495,6 +652,8 @@ function goBack() {
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.bar-fill { transition: none; } .bar-fill {
transition: none;
}
} }
</style> </style>

View File

@@ -57,7 +57,7 @@ import { ref, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app' import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
import { getUsers, updateUserRole, deleteUser } from '@/api/admin' import { getUsers, updateUserRole, deleteUser } from '@/api/admin'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
@@ -74,7 +74,7 @@ const loading = ref(false)
const initialLoaded = ref(false) const initialLoaded = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadUsers(true) await loadUsers(true)
initialLoaded.value = true initialLoaded.value = true
}) })
@@ -96,7 +96,7 @@ async function loadUsers(reset = false) {
} }
loading.value = true loading.value = true
try { try {
const params: any = { page: page.value, pageSize: 20 } const params: { page: number; pageSize: number; keyword?: string } = { page: page.value, pageSize: 20 }
if (keyword.value.trim()) params.keyword = keyword.value.trim() if (keyword.value.trim()) params.keyword = keyword.value.trim()
const data = await getUsers(params) const data = await getUsers(params)
if (reset) { if (reset) {
@@ -112,12 +112,6 @@ async function loadUsers(reset = false) {
} }
} }
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
const d = new Date(dateStr) const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
@@ -154,7 +148,7 @@ function handleDelete(user: AdminUser) {
success: async (res) => { success: async (res) => {
if (res.confirm) { if (res.confirm) {
try { try {
await deleteUser(user.id) await deleteUser(user.id, { confirmName: user.nickname })
list.value = list.value.filter(u => u.id !== user.id) list.value = list.value.filter(u => u.id !== user.id)
uni.showToast({ title: '已删除', icon: 'success' }) uni.showToast({ title: '已删除', icon: 'success' })
} catch (e: any) { } catch (e: any) {

View File

@@ -0,0 +1,302 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="nav-title">数据备份</text>
<view class="nav-placeholder"></view>
</view>
</view>
<view class="action-bar">
<view class="create-btn" :class="{ disabled: creating }" @tap="onCreate">
<Icon name="plus" :size="28" color="#FFFFFF" />
<text class="create-text">{{ creating ? '创建中...' : '创建备份' }}</text>
</view>
</view>
<view class="backup-list" v-if="backups.length > 0">
<view v-for="item in backups" :key="item.id" class="backup-item">
<view class="bk-info">
<view class="bk-name-row">
<Icon name="archive" :size="28" :color="item.status === 'completed' ? '#7BC67E' : '#BFB3B3'" />
<text class="bk-name">{{ item.name }}</text>
</view>
<text class="bk-meta">{{ formatDate(item.created_at) }} · {{ formatSize(item.size) }}</text>
<text class="bk-status" :class="item.status">
{{ item.status === 'completed' ? '已完成' : item.status === 'pending' ? '处理中' : '失败' }}
</text>
</view>
<view class="bk-actions" v-if="item.status === 'completed'">
<view class="bk-btn download" @tap="onDownload(item)">
<Icon name="download" :size="24" color="#5B9BD5" />
</view>
<view class="bk-btn delete" @tap="onDelete(item)">
<Icon name="trash" :size="24" color="#FF6B6B" />
</view>
</view>
</view>
</view>
<view class="empty" v-else-if="!loading">
<Icon name="archive" :size="48" color="#BFB3B3" />
<text class="empty-text">暂无备份记录</text>
</view>
<view class="loading-box" v-if="loading">
<text class="loading-text">加载中...</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { getBackups, createBackup, deleteBackup } from '@/api/backup'
import type { Backup } from '@/api/backup'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
import { API_BASE } from '@/config'
const backups = ref<Backup[]>([])
const loading = ref(true)
const creating = ref(false)
const initialLoaded = ref(false)
onMounted(async () => {
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadBackups()
initialLoaded.value = true
})
onShow(() => {
if (initialLoaded.value) loadBackups(true)
})
onPullDownRefresh(async () => {
await loadBackups(true)
uni.stopPullDownRefresh()
})
async function loadBackups(silent = false) {
if (!silent) loading.value = true
try {
const data = await getBackups({ page: 1, pageSize: 50 })
backups.value = data.list || []
} catch {
backups.value = []
} finally {
loading.value = false
}
}
async function onCreate() {
if (creating.value) return
creating.value = true
try {
await createBackup()
uni.showToast({ title: '备份创建成功', icon: 'success' })
await loadBackups()
} catch {
uni.showToast({ title: '创建失败', icon: 'none' })
} finally {
creating.value = false
}
}
function onDownload(item: Backup) {
if (!item.downloadToken) {
uni.showToast({ title: '下载链接无效', icon: 'none' })
return
}
// 在新窗口打开下载链接H5
// #ifdef H5
const apiBase = '/api'
const url = `${apiBase}/backup/${item.id}/download?token=${encodeURIComponent(item.downloadToken)}`
window.open(url, '_blank')
// #endif
// #ifdef MP-WEIXIN
uni.downloadFile({
url: `${API_BASE}/backup/${item.id}/download?token=${encodeURIComponent(item.downloadToken)}`,
success: (res) => {
if (res.statusCode === 200) {
uni.openDocument({
filePath: res.tempFilePath,
showMenu: true,
fail: () => uni.showToast({ title: '打开失败', icon: 'none' })
})
}
},
fail: () => uni.showToast({ title: '下载失败', icon: 'none' })
})
// #endif
}
async function onDelete(item: Backup) {
uni.showModal({
title: '删除备份',
content: `确定删除备份「${item.name}」?`,
success: async (res) => {
if (res.confirm) {
try {
await deleteBackup(item.id)
backups.value = backups.value.filter(b => b.id !== item.id)
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
function goBack() {
uni.navigateBack()
}
function formatDate(dateStr: string): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function formatSize(bytes: number): string {
if (!bytes || bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(1)} ${units[i]}`
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
@include page-base;
}
.header-fixed { @include sticky-header; }
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: $space-sm $space-lg;
}
.nav-back {
@include nav-back;
border-radius: 50%;
&:active { background: $border; }
}
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
.nav-placeholder { width: 72rpx; }
.action-bar {
padding: $space-lg $space-xl;
}
.create-btn {
@include btn-primary;
gap: $space-sm;
width: 100%;
&.disabled { opacity: 0.5; pointer-events: none; }
}
.create-text { @include btn-primary-text; }
.backup-list { padding: 0 $space-xl; }
.backup-item {
@include card($radius-xl);
padding: $space-lg;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $space-sm;
}
.bk-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.bk-name-row {
display: flex;
align-items: center;
gap: $space-xs;
}
.bk-name {
font-size: $font-lg;
font-weight: 500;
color: $text;
}
.bk-meta {
font-size: $font-sm;
color: $text-muted;
}
.bk-status {
font-size: $font-xs;
padding: 2rpx 12rpx;
border-radius: $radius-xs;
width: fit-content;
&.completed { color: $success; background: rgba(123, 198, 126, 0.1); }
&.pending { color: $primary; background: $primary-light; }
&.failed { color: $danger; background: rgba(255, 107, 107, 0.1); }
}
.bk-actions {
display: flex;
gap: $space-xs;
}
.bk-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $bg;
&:active { background: $border; }
}
.empty {
@include state-box;
padding: 120rpx 0;
}
.empty-text { @include state-text; }
.loading-box {
@include flex-center;
padding: 120rpx 0;
}
.loading-text { font-size: $font-lg; color: $text-muted; }
</style>

View File

@@ -17,6 +17,25 @@
</view> </view>
</view> </view>
<!-- 标签筛选条 -->
<scroll-view class="tag-filter-scroll" scroll-x v-if="tagStore.tags.length > 0">
<view class="tag-filter-row">
<view class="tag-filter-chip" :class="{ active: filterTagId === null }" @tap="filterTagId = null; loadTx(true)">
<text class="tag-filter-text">全部标签</text>
</view>
<view
v-for="tag in tagStore.tags"
:key="tag.id"
class="tag-filter-chip"
:class="{ active: filterTagId === tag.id }"
@tap="filterTagId = tag.id; loadTx(true)"
>
<view class="tag-filter-dot" :style="{ background: tag.color }"></view>
<text class="tag-filter-text">{{ tag.name }}</text>
</view>
</view>
</scroll-view>
<!-- 筛选结果统计 --> <!-- 筛选结果统计 -->
<view class="filter-stats" v-if="hasAdvancedFilter && !loading"> <view class="filter-stats" v-if="hasAdvancedFilter && !loading">
<text class="filter-stats-text"> {{ total }} </text> <text class="filter-stats-text"> {{ total }} </text>
@@ -43,6 +62,8 @@
:hideDate="true" :hideDate="true"
:showCreator="groupStore.isGroupMode" :showCreator="groupStore.isGroupMode"
:currentUserId="userStore.userInfo?.id" :currentUserId="userStore.userInfo?.id"
:disableSwipe="groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id"
:swipeHint="!swipeHintDismissed && isFirstDeletable(item)"
@item-tap="onEdit(item)" @item-tap="onEdit(item)"
@delete="onDelete(item)" @delete="onDelete(item)"
/> />
@@ -79,9 +100,16 @@
<text class="state-text">加载失败下拉刷新重试</text> <text class="state-text">加载失败下拉刷新重试</text>
</view> </view>
<view class="empty" v-if="txList.length === 0 && !loading && !loadError"> <view class="state-box" v-if="txList.length === 0 && !loading && !loadError">
<text class="empty-text">暂无账单记录</text> <Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">暂无账单记录</text>
</view> </view>
<Snackbar
:visible="snackbarVisible"
:message="snackbarMsg"
@undo="onUndoDelete"
/>
</view> </view>
</template> </template>
@@ -90,27 +118,68 @@ import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app' import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { useTagStore } from '@/stores/tag'
import { useTransactionStore } from '@/stores/transaction'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { getTransactions, deleteTransaction } from '@/api/transaction' import { getTransactions } from '@/api/transaction'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue' import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue' import Skeleton from '@/components/Skeleton/Skeleton.vue'
import Snackbar from '@/components/Snackbar/Snackbar.vue'
import FilterPanel from './filter-panel.vue' import FilterPanel from './filter-panel.vue'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format' import { formatAmount, formatDate } from '@/utils/format'
import type { Transaction, TransactionParams } from '@/api/transaction'
import type { FilterParams } from '@/api/filter' import type { FilterParams } from '@/api/filter'
const userStore = useUserStore() const userStore = useUserStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const tagStore = useTagStore()
const txStore = useTransactionStore()
const loading = ref(false) const loading = ref(false)
const filterType = ref('all') const filterType = ref('all')
const filterTagId = ref<number | null>(null)
const page = ref(1) const page = ref(1)
const pageSize = 20 const pageSize = 20
const txList = ref<any[]>([]) const txList = ref<Transaction[]>([])
const total = ref(0) const total = ref(0)
const loadError = ref(false) const loadError = ref(false)
let loadSeq = 0 // 请求序号,防止并发覆盖 let loadSeq = 0 // 请求序号,防止并发覆盖
// Snackbar 删除撤销相关
const snackbarVisible = ref(false)
const snackbarMsg = ref('')
let snackbarTimer: number | null = null
// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复
const swipeHintDismissed = ref(false)
const HINT_KEY = 'xiaocai_swipe_hint_shown'
function isFirstDeletable(item: Transaction): boolean {
if (swipeHintDismissed.value) return false
// 群组模式下只有自己的记录可删除
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) return false
// 找到第一个可删除的项
for (const group of groupedTx.value) {
for (const tx of group.items) {
if (groupStore.isGroupMode && tx.user_id !== userStore.userInfo?.id) continue
return tx.id === item.id
}
}
return false
}
// 动画播完后标记已显示
try {
if (uni.getStorageSync(HINT_KEY)) {
swipeHintDismissed.value = true
}
} catch {}
setTimeout(() => {
swipeHintDismissed.value = true
try { uni.setStorageSync(HINT_KEY, '1') } catch {}
}, 3000)
// 高级筛选 // 高级筛选
const showFilter = ref(false) const showFilter = ref(false)
const advancedFilters = ref<FilterParams>({}) const advancedFilters = ref<FilterParams>({})
@@ -142,7 +211,14 @@ const groupedTx = computed(() => {
const initialLoaded = ref(false) const initialLoaded = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() try {
await waitForReady()
} catch {
// waitForReady 超时:显示网络异常提示
loadError.value = true
return
}
tagStore.fetchTags().catch(() => {})
loadTx(true).finally(() => { initialLoaded.value = true }) loadTx(true).finally(() => { initialLoaded.value = true })
}) })
@@ -152,6 +228,7 @@ onShow(() => {
}) })
function switchFilter(type: string) { function switchFilter(type: string) {
if (loading.value) return // 防止重复提交
filterType.value = type filterType.value = type
loadTx(true) loadTx(true)
} }
@@ -162,7 +239,7 @@ async function loadTx(reset = false, silent = false) {
if (!silent) txList.value = [] if (!silent) txList.value = []
} }
const seq = ++loadSeq const seq = ++loadSeq
const params: any = { page: page.value, pageSize, group_id: groupStore.currentGroupId } const params: TransactionParams & { group_id?: number | null; category_ids?: string } = { page: page.value, pageSize, group_id: groupStore.currentGroupId }
if (filterType.value !== 'all') params.type = filterType.value if (filterType.value !== 'all') params.type = filterType.value
// 合并高级筛选参数 // 合并高级筛选参数
const af = advancedFilters.value const af = advancedFilters.value
@@ -172,6 +249,7 @@ async function loadTx(reset = false, silent = false) {
if (af.minAmount) params.minAmount = af.minAmount if (af.minAmount) params.minAmount = af.minAmount
if (af.maxAmount) params.maxAmount = af.maxAmount if (af.maxAmount) params.maxAmount = af.maxAmount
if (af.keyword) params.keyword = af.keyword if (af.keyword) params.keyword = af.keyword
if (filterTagId.value) params.tagId = filterTagId.value
try { try {
loading.value = true loading.value = true
loadError.value = false loadError.value = false
@@ -203,7 +281,7 @@ function onFilterApply(filters: FilterParams) {
function onEdit(item: any) { function onEdit(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) { if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '能编辑自己的记录', icon: 'none' }) uni.showToast({ title: '能编辑他人的记录', icon: 'none' })
return return
} }
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` }) uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
@@ -214,33 +292,48 @@ async function onDelete(item: any) {
uni.showToast({ title: '只能删除自己的记录', icon: 'none' }) uni.showToast({ title: '只能删除自己的记录', icon: 'none' })
return return
} }
uni.showModal({ // 乐观删除:先从列表移除,显示 Snackbar 供撤销
title: '删除记录', const itemIndex = txList.value.findIndex(t => t.id === item.id)
content: `确定删除这笔 ${item.type === 'expense' ? '支出' : '收入'} ¥${(item.amount / 100).toFixed(2)}`, const started = txStore.startDelete(item.id, item, itemIndex)
success: async (res) => { if (!started) {
if (res.confirm) { uni.showToast({ title: '删除失败,请刷新后重试', icon: 'none' })
try { return
await deleteTransaction(item.id) }
txList.value = txList.value.filter(t => t.id !== item.id) txList.value = txList.value.filter(t => t.id !== item.id)
total.value-- total.value = Math.max(0, total.value - 1)
// 更新筛选金额 // 更新筛选金额
if (item.type === 'expense') { if (item.type === 'expense') {
filteredExpense.value = Math.max(0, filteredExpense.value - item.amount) filteredExpense.value = Math.max(0, filteredExpense.value - item.amount)
} else { } else {
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount) filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
} }
uni.showToast({ title: '已删除', icon: 'success' }) // 显示 Snackbar清理旧定时器避免连续删除时旧定时器误隐藏新提示
} catch { snackbarMsg.value = '已删除 1 条记录'
uni.showToast({ title: '删除失败', icon: 'none' }) snackbarVisible.value = true
} if (snackbarTimer) clearTimeout(snackbarTimer)
} snackbarTimer = setTimeout(async () => {
} snackbarVisible.value = false
}) snackbarTimer = null
const ok = await txStore.confirmDelete()
if (!ok) loadTx(true, true)
}, 3000) as unknown as number
}
/** 撤销删除 */
function onUndoDelete() {
txStore.cancelDelete()
snackbarVisible.value = false
if (snackbarTimer) {
clearTimeout(snackbarTimer)
snackbarTimer = null
}
// 重新加载以恢复完整列表
loadTx(true)
} }
// 触底加载更多 // 触底加载更多
onReachBottom(() => { onReachBottom(() => {
if (noMore.value || loading) return if (noMore.value || loading.value) return
page.value++ page.value++
loadTx(false) loadTx(false)
}) })
@@ -369,13 +462,6 @@ onPullDownRefresh(() => {
.loading-text { font-size: $font-md; color: $text-muted; } .loading-text { font-size: $font-md; color: $text-muted; }
.empty {
text-align: center;
padding: 120rpx 0;
}
.empty-text { font-size: $font-lg; color: $text-muted; }
.state-box { .state-box {
@include state-box; @include state-box;
justify-content: center; justify-content: center;
@@ -384,4 +470,51 @@ onPullDownRefresh(() => {
.state-text { .state-text {
@include state-text; @include state-text;
} }
/* 标签筛选条 */
.tag-filter-scroll {
white-space: nowrap;
padding: 0 $space-xl $space-sm;
}
.tag-filter-row {
display: inline-flex;
gap: $space-xs;
}
.tag-filter-chip {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: $space-xs $space-md;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
flex-shrink: 0;
transition: all $transition-normal;
&.active {
background: $primary-light;
border-color: $primary;
}
&:active { opacity: 0.7; }
}
.tag-filter-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
}
.tag-filter-text {
font-size: $font-sm;
color: $text-sec;
.active & {
color: $primary;
font-weight: 500;
}
}
</style> </style>

View File

@@ -60,15 +60,15 @@
<!-- 自定义金额 --> <!-- 自定义金额 -->
<view class="section-title">自定义金额</view> <view class="section-title">自定义金额</view>
<view class="custom-input"> <AmountEditor
<text class="input-prefix">¥</text> ref="amountEditorRef"
<text class="input-value" :class="{ placeholder: !amountStr }">{{ amountStr }}</text> v-model="amountStr"
<text class="input-cursor">|</text> :fixed="true"
<text class="input-unit"></text> size="large"
</view> unit="元"
:auto-focus="false"
<!-- Numpad --> @confirm="onConfirm"
<Numpad v-model="amountStr" @confirm="onConfirm" /> />
</view> </view>
</view> </view>
</template> </template>
@@ -81,21 +81,23 @@ import { useGroupStore } from '@/stores/group'
import { getOverview } from '@/api/stats' import { getOverview } from '@/api/stats'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { formatAmount, getCurrentMonth } from '@/utils/format' import { formatAmount, getCurrentMonth } from '@/utils/format'
import type { Budget } from '@/api/budget'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue' import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
import Numpad from '@/components/Numpad/Numpad.vue' import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
const budgetStore = useBudgetStore() const budgetStore = useBudgetStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const currentMonth = getCurrentMonth() const currentMonth = getCurrentMonth()
const budget = ref<any>(null) const budget = ref<Budget | null>(null)
const budgetAmount = ref(0) const budgetAmount = ref(0)
const monthExpense = ref(0) const monthExpense = ref(0)
const loading = ref(true) const loading = ref(true)
const initialLoaded = ref(false) const initialLoaded = ref(false)
const amountStr = ref('') const amountStr = ref('')
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const presets = [1000, 2000, 3000, 5000, 10000] const presets = [1000, 2000, 3000, 5000, 10000]
@@ -125,7 +127,7 @@ async function fetchMyExpense() {
} }
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
loading.value = true loading.value = true
try { try {
await budgetStore.fetchBudget(currentMonth) await budgetStore.fetchBudget(currentMonth)
@@ -161,6 +163,7 @@ onPullDownRefresh(async () => {
function selectPreset(val: number) { function selectPreset(val: number) {
amountStr.value = String(val) amountStr.value = String(val)
amountEditorRef.value?.focus()
} }
async function onConfirm() { async function onConfirm() {
@@ -194,7 +197,10 @@ function goBack() {
.page { .page {
min-height: 100vh; min-height: 100vh;
background: $bg; background: $bg;
padding-bottom: 120rpx; // AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
$numpad-h: 464rpx;
padding-bottom: calc(#{$numpad-h} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$numpad-h} + env(safe-area-inset-bottom));
} }
.header-fixed { .header-fixed {
@@ -366,60 +372,11 @@ function goBack() {
color: $text; color: $text;
} }
.custom-input {
display: flex;
align-items: baseline;
gap: $space-xs;
margin-bottom: $space-lg;
padding: $space-md $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
}
.input-prefix {
font-family: 'Fredoka', sans-serif;
font-size: $font-3xl;
font-weight: 600;
color: $primary;
}
.input-value {
font-family: 'Fredoka', sans-serif;
font-size: $font-4xl;
font-weight: 600;
color: $text;
flex: 1;
&.placeholder {
color: #D0C4C4;
}
}
.input-unit {
font-size: $font-lg;
color: $text-sec;
}
.input-cursor {
font-family: 'Fredoka', monospace;
font-size: $font-4xl;
font-weight: 300;
color: $primary;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.preview-skeleton { .preview-skeleton {
animation: none; animation: none;
} }
.input-cursor {
animation: none;
opacity: 1;
}
} }
</style> </style>

View File

@@ -19,7 +19,10 @@
</view> </view>
<view class="cat-list"> <view class="cat-list">
<view v-for="cat in currentCategories" :key="cat.id" class="cat-row"> <view v-for="cat in currentCategories" :key="cat.id" class="cat-row" :data-id="cat.id">
<view class="cat-drag-handle" @touchstart="onDragStart" @touchmove="onDragMove" @touchend="onDragEnd" v-if="tabType === 'expense' || tabType === 'income'">
<Icon name="dragHandle" :size="24" color="#BFB3B3" />
</view>
<view class="cat-info" @tap="cat.is_custom && onEdit(cat)"> <view class="cat-info" @tap="cat.is_custom && onEdit(cat)">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" /> <CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" />
<text class="cat-name">{{ cat.name }}</text> <text class="cat-name">{{ cat.name }}</text>
@@ -42,46 +45,22 @@
</view> </view>
<!-- 添加弹窗 --> <!-- 添加弹窗 -->
<view class="modal-mask" v-if="showAddModal" @tap="showAddModal = false"> <EditModal
<view class="modal" @tap.stop> v-model:visible="showAddModal"
<text class="modal-title">添加自定义分类</text> title="添加自定义分类"
<input class="modal-input" v-model="newName" placeholder="分类名称" maxlength="10" /> :fields="catFields"
<view class="color-picker"> :modelValue="addModel"
<view @confirm="onAdd"
v-for="c in colorOptions" :key="c" />
class="color-dot"
:class="{ selected: selectedColor === c }"
:style="{ background: c }"
@tap="selectedColor = c"
/>
</view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showAddModal = false">取消</view>
<view class="modal-btn confirm" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
</view>
</view>
</view>
<!-- 编辑弹窗 --> <!-- 编辑弹窗 -->
<view class="modal-mask" v-if="showEditModal" @tap="showEditModal = false"> <EditModal
<view class="modal" @tap.stop> v-model:visible="showEditModal"
<text class="modal-title">编辑分类</text> title="编辑分类"
<input class="modal-input" v-model="editName" placeholder="分类名称" maxlength="10" /> :fields="catFields"
<view class="color-picker"> :modelValue="editModel"
<view @confirm="onEditConfirm"
v-for="c in colorOptions" :key="c" />
class="color-dot"
:class="{ selected: editColor === c }"
:style="{ background: c }"
@tap="editColor = c"
/>
</view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showEditModal = false">取消</view>
<view class="modal-btn confirm" @tap="onEditConfirm">确定</view>
</view>
</view>
</view>
<!-- 迁移弹窗 --> <!-- 迁移弹窗 -->
<view class="modal-mask" v-if="showMigrateModal" @tap="showMigrateModal = false"> <view class="modal-mask" v-if="showMigrateModal" @tap="showMigrateModal = false">
@@ -117,20 +96,31 @@ import { waitForReady } from '@/utils/app-ready'
import { getCategoryTransactionCount } from '@/api/category' import { getCategoryTransactionCount } from '@/api/category'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import EditModal from '@/components/EditModal/EditModal.vue'
import type { EditField } from '@/components/EditModal/EditModal.vue'
import { statusBarHeight, capsuleRight } from '@/utils/system' import { statusBarHeight, capsuleRight } from '@/utils/system'
const catStore = useCategoryStore() const catStore = useCategoryStore()
const tabType = ref<'expense' | 'income'>('expense') const tabType = ref<'expense' | 'income'>('expense')
const showAddModal = ref(false) const showAddModal = ref(false)
const newName = ref('') const showEditModal = ref(false)
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
const selectedColor = ref(colorOptions[0]) /** 分类添加/编辑弹窗字段配置 */
const catFields: EditField[] = [
{ key: 'name', label: '分类名称', type: 'text', placeholder: '分类名称', maxlength: 10 },
{ key: 'color', label: '颜色', type: 'color' },
]
const addModel = ref({ name: '', color: '#FF8C69' })
const editModel = ref({ name: '', color: '#FF8C69' })
// 拖拽排序相关
let dragStartY = 0
let dragCurrentIndex = -1
let dragItemEl: any = null
// 编辑相关 // 编辑相关
const showEditModal = ref(false)
const editingCat = ref<Category | null>(null) const editingCat = ref<Category | null>(null)
const editName = ref('')
const editColor = ref('')
// 迁移相关 // 迁移相关
const showMigrateModal = ref(false) const showMigrateModal = ref(false)
@@ -148,18 +138,18 @@ const migrateTargets = computed(() => {
const initialLoaded = ref(false) const initialLoaded = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await catStore.fetchCategories() await catStore.fetchCategories()
initialLoaded.value = true initialLoaded.value = true
}) })
// 返回页面时静默刷新 // 返回页面时静默刷新(使用缓存)
onShow(() => { onShow(() => {
if (initialLoaded.value) catStore.fetchCategories() if (initialLoaded.value) catStore.fetchCategories() // 不强制刷新(使用缓存)
}) })
onPullDownRefresh(async () => { onPullDownRefresh(async () => {
await catStore.fetchCategories() await catStore.fetchCategories(true) // 强制刷新
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
}) })
@@ -167,18 +157,18 @@ function goBack() {
uni.navigateBack() uni.navigateBack()
} }
async function onAdd() { async function onAdd(data: Record<string, any>) {
const name = newName.value.trim() const name = (data.name || '').toString().trim()
if (!name) return if (!name) return
try { try {
await catStore.addCategory({ await catStore.addCategory({
name, name,
icon: name[0], icon: name[0],
color: selectedColor.value, color: data.color,
type: tabType.value type: tabType.value
}) })
newName.value = '' addModel.value = { name: '', color: '#FF8C69' }
showAddModal.value = false showAddModal.value = false
uni.showToast({ title: '添加成功', icon: 'success' }) uni.showToast({ title: '添加成功', icon: 'success' })
} catch { } catch {
@@ -188,21 +178,20 @@ async function onAdd() {
function onEdit(cat: Category) { function onEdit(cat: Category) {
editingCat.value = cat editingCat.value = cat
editName.value = cat.name editModel.value = { name: cat.name, color: cat.color }
editColor.value = cat.color
showEditModal.value = true showEditModal.value = true
} }
async function onEditConfirm() { async function onEditConfirm(data: Record<string, any>) {
if (!editingCat.value) return if (!editingCat.value) return
const name = editName.value.trim() const name = (data.name || '').toString().trim()
if (!name) { if (!name) {
uni.showToast({ title: '请输入名称', icon: 'none' }) uni.showToast({ title: '请输入名称', icon: 'none' })
return return
} }
try { try {
await catStore.updateCategory(editingCat.value.id, { name, color: editColor.value }) await catStore.updateCategory(editingCat.value.id, { name, color: data.color })
showEditModal.value = false showEditModal.value = false
uni.showToast({ title: '修改成功', icon: 'success' }) uni.showToast({ title: '修改成功', icon: 'success' })
} catch { } catch {
@@ -270,6 +259,52 @@ async function onMigrateConfirm() {
uni.showToast({ title: '操作失败', icon: 'none' }) uni.showToast({ title: '操作失败', icon: 'none' })
} }
} }
/** 拖拽排序:开始 */
function onDragStart(e: any) {
dragStartY = e.touches[0].clientY
dragItemEl = e.currentTarget?.parentElement
if (dragItemEl) {
const id = Number(dragItemEl.dataset?.id)
const idx = currentCategories.value.findIndex(c => c.id === id)
dragCurrentIndex = idx
dragItemEl.style?.opacity && (dragItemEl.style.opacity = '0.6')
}
}
/** 拖拽排序:移动 */
function onDragMove(e: any) {
// 视觉反馈由 CSS transition 处理,这里仅用于阻止页面滚动
}
/** 拖拽排序:结束 */
function onDragEnd(e: any) {
if (dragItemEl) {
dragItemEl.style?.opacity && (dragItemEl.style.opacity = '1')
}
const endY = e.changedTouches[0].clientY
const delta = endY - dragStartY
const threshold = 50
if (Math.abs(delta) > threshold && dragCurrentIndex >= 0) {
const direction = delta > 0 ? 1 : -1
const newIndex = dragCurrentIndex + direction
const cats = [...currentCategories.value]
if (newIndex >= 0 && newIndex < cats.length) {
// 交换位置
const temp = cats[dragCurrentIndex]
cats[dragCurrentIndex] = cats[newIndex]
cats[newIndex] = temp
// 保存排序
const sortedIds = cats.map(c => c.id)
catStore.sortCategories(sortedIds).catch(() => {})
}
}
dragCurrentIndex = -1
dragItemEl = null
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -337,6 +372,16 @@ async function onMigrateConfirm() {
&:last-child { border-bottom: none; } &:last-child { border-bottom: none; }
} }
.cat-drag-handle {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&:active { opacity: 0.6; }
}
.cat-info { .cat-info {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -376,7 +421,7 @@ async function onMigrateConfirm() {
.fab { .fab {
position: fixed; position: fixed;
right: $space-xl; right: $space-xl;
bottom: 120rpx; bottom: calc(120rpx + env(safe-area-inset-bottom));
width: 112rpx; width: 112rpx;
height: 112rpx; height: 112rpx;
background: linear-gradient(135deg, $primary, #E67355); background: linear-gradient(135deg, $primary, #E67355);
@@ -390,27 +435,7 @@ async function onMigrateConfirm() {
&:active { transform: scale(0.95); } &:active { transform: scale(0.95); }
} }
.color-picker { /* 迁移弹窗样式 */
display: flex;
gap: $space-lg;
margin-bottom: $space-md;
flex-wrap: wrap;
}
.color-dot {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid transparent;
transition: all $transition-normal;
&.selected {
border-color: $text;
transform: scale(1.15);
}
&:active { opacity: 0.7; }
}
.modal-mask { .modal-mask {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
@@ -444,15 +469,6 @@ async function onMigrateConfirm() {
margin-bottom: $space-md; margin-bottom: $space-md;
} }
.modal-input {
height: 96rpx;
background: $bg;
border-radius: $space-md;
padding: 0 $space-lg;
font-size: $font-lg;
margin-bottom: $space-md;
}
.migrate-list { .migrate-list {
max-height: 400rpx; max-height: 400rpx;
margin-bottom: $space-md; margin-bottom: $space-md;

View File

@@ -0,0 +1,343 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="nav-title">数据导入</text>
<view class="nav-placeholder"></view>
</view>
</view>
<view class="content">
<view class="section">
<text class="section-title">选择文件</text>
<text class="section-desc">支持 CSVJSON 格式文件需小于 5MB</text>
<view class="file-picker" @tap="onPickFile">
<Icon name="upload" :size="48" color="#FF8C69" />
<text class="file-picker-text">{{ fileName || '点击选择文件' }}</text>
</view>
</view>
<view class="section" v-if="fileName">
<text class="section-title">文件预览</text>
<text class="preview-info">格式{{ fileFormat?.toUpperCase() }} · 大小{{ fileSize }}</text>
<text class="preview-info">识别到 {{ parsedCount }} 条记录</text>
</view>
<view class="section" v-if="importResult">
<text class="section-title">导入结果</text>
<view class="result-card">
<view class="result-row">
<text class="result-label">成功导入</text>
<text class="result-value success">{{ importResult.imported }} </text>
</view>
<view class="result-row">
<text class="result-label">跳过</text>
<text class="result-value warn">{{ importResult.skipped }} </text>
</view>
</view>
</view>
<view class="action-area" v-if="fileName && !importResult">
<view class="import-btn" :class="{ disabled: importing || parsedCount === 0 }" @tap="onImport">
<text class="import-text">{{ importing ? '导入中...' : '开始导入' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { statusBarHeight } from '@/utils/system'
import { importTransactions } from '@/api/transaction'
import Icon from '@/components/Icon/Icon.vue'
const fileName = ref('')
const fileSize = ref('')
const fileFormat = ref<'csv' | 'json' | null>(null)
const parsedCount = ref(0)
const parsedData = ref<Array<{
date: string
type: 'expense' | 'income'
category_name: string
amount: number
note?: string
}>>([])
const importing = ref(false)
const importResult = ref<{ imported: number; skipped: number } | null>(null)
function goBack() {
uni.navigateBack()
}
function onPickFile() {
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.accept = '.csv,.json'
input.onchange = (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
handleFile(file)
}
input.click()
// #endif
// #ifdef MP-WEIXIN
uni.chooseMessageFile({
count: 1,
type: 'file',
extension: ['.csv', '.json'],
success: (res) => {
const file = res.tempFiles[0]
fileName.value = file.name
fileSize.value = formatFileSize(file.size)
fileFormat.value = file.name.endsWith('.json') ? 'json' : 'csv'
// 读取文件内容
const fs = uni.getFileSystemManager()
try {
const content = fs.readFileSync(file.path, 'utf-8') as string
parseContent(content, fileFormat.value!)
} catch {
uni.showToast({ title: '文件读取失败', icon: 'none' })
}
}
})
// #endif
}
function handleFile(file: File) {
fileName.value = file.name
fileSize.value = formatFileSize(file.size)
fileFormat.value = file.name.endsWith('.json') ? 'json' : 'csv'
// 检查大小
if (file.size > 5 * 1024 * 1024) {
uni.showToast({ title: '文件不能超过 5MB', icon: 'none' })
return
}
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result as string
parseContent(content, fileFormat.value!)
}
reader.readAsText(file, 'utf-8')
}
function parseContent(content: string, format: 'csv' | 'json') {
try {
importResult.value = null
if (format === 'json') {
const items = JSON.parse(content)
if (!Array.isArray(items)) {
uni.showToast({ title: 'JSON 需为数组格式', icon: 'none' })
return
}
parsedData.value = items.map((item: any) => ({
date: item.date || item.日期 || '',
type: (item.type === '收入' || item.type === 'income') ? 'income' as const : 'expense' as const,
category_name: item.category || item.category_name || item.分类 || '未分类',
amount: Math.round((Number(item.amount || item.金额 || 0)) * 100),
note: item.note || item.备注 || ''
})).filter((item: any) => item.date && item.amount > 0)
} else {
// CSV 解析
const lines = content.replace(/^\uFEFF/, '').split('\n').filter(line => line.trim())
if (lines.length < 2) {
uni.showToast({ title: 'CSV 文件为空', icon: 'none' })
return
}
// 跳过表头
parsedData.value = lines.slice(1).map(line => {
const parts = parseCsvLine(line)
return {
date: (parts[0] || '').trim(),
type: (parts[1] || '').includes('收入') ? 'income' as const : 'expense' as const,
category_name: (parts[2] || '未分类').trim(),
amount: Math.round(parseFloat((parts[3] || '0').replace(/,/g, '')) * 100),
note: (parts[4] || '').trim()
}
}).filter(item => item.date && item.amount > 0)
}
parsedCount.value = parsedData.value.length
} catch {
uni.showToast({ title: '文件解析失败', icon: 'none' })
parsedData.value = []
parsedCount.value = 0
}
}
function parseCsvLine(line: string): string[] {
const result: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"'
i++
} else {
inQuotes = !inQuotes
}
} else if (char === ',' && !inQuotes) {
result.push(current)
current = ''
} else {
current += char
}
}
result.push(current)
return result
}
async function onImport() {
if (importing.value || parsedCount.value === 0) return
importing.value = true
try {
const result = await importTransactions({ transactions: parsedData.value })
importResult.value = result
uni.showToast({ title: '导入完成', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '导入失败', icon: 'none' })
} finally {
importing.value = false
}
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(1)} ${units[i]}`
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
@include page-base;
}
.header-fixed { @include sticky-header; }
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: $space-sm $space-lg;
}
.nav-back {
@include nav-back;
border-radius: 50%;
&:active { background: $border; }
}
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
.nav-placeholder { width: 72rpx; }
.content {
padding: $space-xl;
}
.section {
margin-bottom: $space-2xl;
}
.section-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-xs;
}
.section-desc {
font-size: $font-md;
color: $text-muted;
display: block;
margin-bottom: $space-md;
}
.file-picker {
@include card($radius-2xl);
padding: $space-2xl;
display: flex;
flex-direction: column;
align-items: center;
gap: $space-md;
&:active { background: $surface-warm; }
}
.file-picker-text {
font-size: $font-lg;
color: $text-sec;
}
.preview-info {
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: $space-xs;
}
.result-card {
@include card($radius-xl);
padding: $space-lg;
}
.result-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-sm 0;
border-bottom: 2rpx solid $border;
&:last-child { border-bottom: none; }
}
.result-label { font-size: $font-lg; color: $text-sec; }
.result-value {
font-size: $font-lg;
font-weight: 600;
&.success { color: $success; }
&.warn { color: $primary; }
}
.action-area {
padding-top: $space-md;
}
.import-btn {
@include btn-primary;
width: 100%;
&.disabled { opacity: 0.5; pointer-events: none; }
}
.import-text { @include btn-primary-text; }
</style>

View File

@@ -11,7 +11,15 @@
</view> </view>
</view> </view>
<view class="content"> <view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: currentTab === 'submit' }" @tap="currentTab = 'submit'">提交反馈</view>
<view class="tab" :class="{ active: currentTab === 'mine' }" @tap="currentTab = 'mine'">我的反馈</view>
</view>
</view>
<!-- 提交反馈 Tab -->
<view class="content" v-if="currentTab === 'submit'">
<!-- 反馈类型 --> <!-- 反馈类型 -->
<view class="section"> <view class="section">
<text class="label">反馈类型</text> <text class="label">反馈类型</text>
@@ -59,13 +67,45 @@
<text class="tip">感谢您的反馈我们会认真处理每一条意见</text> <text class="tip">感谢您的反馈我们会认真处理每一条意见</text>
</view> </view>
<!-- 我的反馈 Tab -->
<view class="mine-content" v-if="currentTab === 'mine'">
<view class="feedback-list" v-if="myFeedbacks.length > 0">
<view v-for="item in myFeedbacks" :key="item.id" class="fb-item">
<view class="fb-header">
<text class="fb-type">{{ getTypeLabel(item.type) }}</text>
<text class="fb-status" :class="item.status">{{ getStatusText(item.status) }}</text>
</view>
<text class="fb-content">{{ item.content }}</text>
<text class="fb-date">{{ formatDateStr(item.created_at) }}</text>
<view class="fb-reply" v-if="item.admin_reply">
<text class="fb-reply-label">管理员回复</text>
<text class="fb-reply-text">{{ item.admin_reply }}</text>
</view>
</view>
</view>
<view class="empty" v-else-if="!mineLoading">
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="empty-text">暂无反馈记录</text>
</view>
<view class="loading-box" v-if="mineLoading">
<text class="loading-text">加载中...</text>
</view>
<view class="load-more" v-if="!mineLoading && myFeedbacks.length > 0 && !mineNoMore" @tap="loadMoreFeedbacks">
<text class="load-more-text">加载更多</text>
</view>
</view>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { ref, reactive, onMounted, watch } from 'vue'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { submitFeedback } from '@/api/feedback' import { submitFeedback, getMyFeedbacks } from '@/api/feedback'
import type { MyFeedback } from '@/api/feedback'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
const types = [ const types = [
@@ -75,6 +115,7 @@ const types = [
{ label: '其他', value: 'other' } { label: '其他', value: 'other' }
] ]
const currentTab = ref<'submit' | 'mine'>('submit')
const form = reactive({ const form = reactive({
type: 'bug', type: 'bug',
content: '', content: '',
@@ -83,6 +124,50 @@ const form = reactive({
const submitting = ref(false) const submitting = ref(false)
// 我的反馈列表
const myFeedbacks = ref<MyFeedback[]>([])
const mineLoading = ref(false)
const minePage = ref(1)
const mineTotal = ref(0)
const mineNoMore = ref(false)
// 切换到我的反馈 tab 时加载数据
watch(currentTab, (tab) => {
if (tab === 'mine' && myFeedbacks.value.length === 0) {
loadMyFeedbacks(true)
}
})
async function loadMyFeedbacks(reset = false) {
if (mineLoading.value) return
if (reset) {
minePage.value = 1
myFeedbacks.value = []
mineNoMore.value = false
}
mineLoading.value = true
try {
const data = await getMyFeedbacks({ page: minePage.value, pageSize: 20 })
const list = data.list || []
if (reset) {
myFeedbacks.value = list
} else {
myFeedbacks.value = [...myFeedbacks.value, ...list]
}
mineTotal.value = data.total
mineNoMore.value = myFeedbacks.value.length >= data.total
} catch {
// 静默处理
} finally {
mineLoading.value = false
}
}
function loadMoreFeedbacks() {
minePage.value++
loadMyFeedbacks()
}
async function onSubmit() { async function onSubmit() {
if (submitting.value || !form.content.trim()) return if (submitting.value || !form.content.trim()) return
@@ -94,7 +179,12 @@ async function onSubmit() {
contact: form.contact.trim() || undefined contact: form.contact.trim() || undefined
}) })
uni.showToast({ title: '提交成功', icon: 'success' }) uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500) form.content = ''
form.contact = ''
// 刷新我的反馈列表
if (currentTab.value === 'mine') {
loadMyFeedbacks(true)
}
} catch (e: any) { } catch (e: any) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' }) uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally { } finally {
@@ -105,6 +195,22 @@ async function onSubmit() {
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()
} }
function getTypeLabel(type: string): string {
const map: Record<string, string> = { bug: '功能异常', feature: '功能建议', ux: '体验问题', other: '其他' }
return map[type] || type
}
function getStatusText(status: string): string {
const map: Record<string, string> = { pending: '待处理', processed: '已处理', ignored: '已忽略' }
return map[status] || status
}
function formatDateStr(dateStr: string): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -259,4 +365,121 @@ function goBack() {
color: $text-muted; color: $text-muted;
margin-top: $space-lg; margin-top: $space-lg;
} }
/* Tabs */
.tabs-wrap {
@include tabs-wrap;
margin: $space-sm 0 $space-lg;
}
.tabs {
@include tabs;
border-radius: $radius-xl;
}
.tab {
@include tab;
border-radius: $radius-xl;
&.active {
border-radius: $radius-lg;
box-shadow: $shadow-md;
}
}
/* 我的反馈 */
.mine-content {
padding: 0 $space-xl;
}
.feedback-list {
display: flex;
flex-direction: column;
gap: $space-sm;
}
.fb-item {
@include card($radius-xl);
padding: $space-lg;
}
.fb-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $space-sm;
}
.fb-type {
font-size: $font-md;
color: $text-sec;
}
.fb-status {
font-size: $font-sm;
padding: 4rpx 16rpx;
border-radius: $radius-xs;
&.pending { color: $primary; background: $primary-light; }
&.processed { color: $success; background: rgba(123, 198, 126, 0.1); }
&.ignored { color: $text-muted; background: $bg; }
}
.fb-content {
font-size: $font-lg;
color: $text;
display: block;
margin-bottom: $space-sm;
line-height: 1.5;
}
.fb-date {
font-size: $font-sm;
color: $text-muted;
display: block;
}
.fb-reply {
margin-top: $space-sm;
padding: $space-md;
background: $bg;
border-radius: $radius-lg;
border-left: 6rpx solid $success;
}
.fb-reply-label {
font-size: $font-sm;
color: $success;
font-weight: 500;
display: block;
margin-bottom: 4rpx;
}
.fb-reply-text {
font-size: $font-md;
color: $text;
display: block;
line-height: 1.5;
}
.empty {
@include state-box;
padding: 120rpx 0;
}
.empty-text { @include state-text; }
.loading-box {
@include flex-center;
padding: 80rpx 0;
}
.loading-text { font-size: $font-lg; color: $text-muted; }
.load-more {
text-align: center;
padding: $space-lg 0;
}
.load-more-text { font-size: $font-md; color: $primary; }
</style> </style>

View File

@@ -45,13 +45,22 @@
<text class="section-title">我的群组</text> <text class="section-title">我的群组</text>
</view> </view>
<view v-if="groupStore.loading" class="loading-box"> <view v-if="groupStore.loading" class="skeleton-list">
<text class="loading-text">加载中...</text> <view v-for="i in 2" :key="i" class="skeleton-card">
<Skeleton width="80rpx" height="80rpx" variant="circle" />
<view class="skeleton-info">
<Skeleton width="200rpx" height="28rpx" variant="text" />
<Skeleton width="280rpx" height="24rpx" variant="text" />
</view>
</view>
</view> </view>
<view v-else-if="groupStore.groups.length === 0" class="empty-box"> <view v-else-if="groupStore.groups.length === 0" class="empty-box">
<Icon name="user" :size="48" color="#BFB3B3" /> <view class="empty-icon-wrap">
<Icon name="user" :size="48" color="#BFB3B3" />
</view>
<text class="empty-text">还没有加入任何群组</text> <text class="empty-text">还没有加入任何群组</text>
<text class="empty-hint">创建或加入一个群组和家人朋友一起记账</text>
</view> </view>
<view v-else class="group-list"> <view v-else class="group-list">
@@ -145,8 +154,9 @@ import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system' import { statusBarHeight, capsuleRight } from '@/utils/system'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
import type { GroupMember, Group } from '@/api/group' import type { GroupMember, Group } from '@/api/group'
const groupStore = useGroupStore() const groupStore = useGroupStore()
@@ -166,7 +176,7 @@ const members = ref<GroupMember[]>([])
const currentGroup = ref<Group | null>(null) const currentGroup = ref<Group | null>(null)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await groupStore.fetchGroups() await groupStore.fetchGroups()
initialLoaded.value = true initialLoaded.value = true
}) })
@@ -210,13 +220,6 @@ function copyInviteCode(code: string) {
}) })
} }
/** 获取头像 URL */
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
async function handleCreate() { async function handleCreate() {
if (saving.value) return if (saving.value) return
const name = newGroupName.value.trim() const name = newGroupName.value.trim()
@@ -473,7 +476,20 @@ function handleDissolve(group: Group) {
color: $text-sec; color: $text-sec;
} }
.loading-box, .empty-box { // 骨架屏
.skeleton-list { padding: 0 $space-xl; }
.skeleton-card {
display: flex; align-items: center; gap: $space-md;
padding: $space-lg; margin-bottom: $space-sm;
background: $surface; border-radius: $radius-xl;
border: 2rpx solid $border;
}
.skeleton-info {
flex: 1; display: flex; flex-direction: column; gap: $space-xs;
}
// 空状态
.empty-box {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -482,8 +498,20 @@ function handleDissolve(group: Group) {
padding: 80rpx 0; padding: 80rpx 0;
} }
.loading-text, .empty-text { .empty-icon-wrap {
font-size: $font-lg; width: 120rpx; height: 120rpx; border-radius: 50%;
background: $surface-warm; @include flex-center;
margin-bottom: $space-sm;
}
.empty-text {
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.empty-hint {
font-size: $font-md;
color: $text-muted; color: $text-muted;
} }
@@ -645,6 +673,7 @@ function handleDissolve(group: Group) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
padding-bottom: env(safe-area-inset-bottom);
} }
.modal-header { .modal-header {

View File

@@ -108,7 +108,17 @@
<text class="state-text">加载失败下拉刷新重试</text> <text class="state-text">加载失败下拉刷新重试</text>
</view> </view>
<view class="state-box" v-if="!loading && !loadError && recentTx.length === 0"> <!-- 网络异常全屏提示 -->
<view class="network-error" v-if="networkError">
<Icon name="alert-circle" :size="64" color="#BFB3B3" />
<text class="network-error-title">网络连接异常</text>
<text class="network-error-desc">请检查网络后点击重试</text>
<view class="network-error-btn" @tap="retryNetwork">
<text class="network-error-btn-text">重新加载</text>
</view>
</view>
<view class="state-box" v-if="!loading && !loadError && !networkError && recentTx.length === 0">
<Icon name="edit" :size="48" color="#BFB3B3" /> <Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">还没有记录快去记一笔吧</text> <text class="state-text">还没有记录快去记一笔吧</text>
</view> </view>
@@ -146,6 +156,8 @@ import { useGroupStore } from '@/stores/group'
import { useNotificationStore } from '@/stores/notification' import { useNotificationStore } from '@/stores/notification'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { getOverview } from '@/api/stats' import { getOverview } from '@/api/stats'
import { syncRecurring } from '@/api/recurring'
import type { Transaction } from '@/api/transaction'
import { getNotificationImageUrl } from '@/api/notification' import { getNotificationImageUrl } from '@/api/notification'
import { formatAmount, formatAmountRaw } from '@/utils/format' import { formatAmount, formatAmountRaw } from '@/utils/format'
import { statusBarHeight, capsuleRight } from '@/utils/system' import { statusBarHeight, capsuleRight } from '@/utils/system'
@@ -165,9 +177,10 @@ const DEFAULT_BUDGET = 500000 // 5000元
const overview = computed(() => statsStore.overview) const overview = computed(() => statsStore.overview)
const budget = computed(() => budgetStore.budget) const budget = computed(() => budgetStore.budget)
const recentTx = ref<any[]>([]) const recentTx = ref<Transaction[]>([])
const loading = ref(true) const loading = ref(true)
const loadError = ref(false) const loadError = ref(false)
const networkError = ref(false)
const todayExpense = ref(0) const todayExpense = ref(0)
const todayIncome = ref(0) const todayIncome = ref(0)
const todayCount = ref(0) const todayCount = ref(0)
@@ -202,15 +215,15 @@ function openLink(url: string) {
// #endif // #endif
} }
// 数据新鲜度控制30秒内不重复请求 async function loadData(silent = false) {
let lastLoadTime = 0 try {
const CACHE_TTL = 30000 await waitForReady()
} catch {
async function loadData(silent = false, force = false) { // waitForReady 超时:显示网络异常提示
await waitForReady() if (!silent) {
networkError.value = true
// 数据新鲜度判断30秒内不重复请求 loading.value = false
if (!force && silent && Date.now() - lastLoadTime < CACHE_TTL) { }
return return
} }
@@ -224,7 +237,6 @@ async function loadData(silent = false, force = false) {
txStore.fetchTransactions({ page: 1 }) txStore.fetchTransactions({ page: 1 })
]) ])
recentTx.value = txStore.transactions.slice(0, 5) recentTx.value = txStore.transactions.slice(0, 5)
lastLoadTime = Date.now()
// 第二级:次要数据(异步,不阻塞首屏) // 第二级:次要数据(异步,不阻塞首屏)
Promise.all([ Promise.all([
@@ -236,7 +248,8 @@ async function loadData(silent = false, force = false) {
Promise.allSettled([ Promise.allSettled([
userStore.fetchUserInfo(), userStore.fetchUserInfo(),
notifStore.fetchUnreadCount(), notifStore.fetchUnreadCount(),
notifStore.fetchUrgentNotifications() notifStore.fetchUrgentNotifications(),
syncRecurring().catch(() => {})
]).catch(() => {}) ]).catch(() => {})
} catch (e) { } catch (e) {
console.error('Load error:', e) console.error('Load error:', e)
@@ -268,11 +281,11 @@ onMounted(() => {
// Refresh data when returning from other pages (e.g., after adding a transaction) // Refresh data when returning from other pages (e.g., after adding a transaction)
onShow(() => { onShow(() => {
if (initialLoaded.value && !loading.value) loadData(true, false) if (initialLoaded.value && !loading.value) loadData(true)
}) })
onPullDownRefresh(() => { onPullDownRefresh(() => {
loadData(false, true).finally(() => uni.stopPullDownRefresh()) loadData().finally(() => uni.stopPullDownRefresh())
}) })
function goAdd(type: string) { function goAdd(type: string) {
@@ -285,7 +298,7 @@ function goNotifications() {
function onTxTap(item: any) { function onTxTap(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) { if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '能编辑自己的记录', icon: 'none' }) uni.showToast({ title: '能编辑他人的记录', icon: 'none' })
return return
} }
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` }) uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
@@ -294,6 +307,12 @@ function onTxTap(item: any) {
function goBills() { function goBills() {
uni.switchTab({ url: '/pages/bills/index' }) uni.switchTab({ url: '/pages/bills/index' })
} }
/** 网络异常重试 */
function retryNetwork() {
networkError.value = false
loadData()
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -371,6 +390,7 @@ function goBills() {
padding: $space-2xl; padding: $space-2xl;
@include card($radius-2xl); @include card($radius-2xl);
box-shadow: $shadow-clay; box-shadow: $shadow-clay;
@include fade-in-up;
} }
.card-label { .card-label {
@@ -400,6 +420,7 @@ function goBills() {
font-weight: 600; font-weight: 600;
color: $text; color: $text;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@include count-up;
} }
.mini-stats { .mini-stats {
@@ -441,6 +462,7 @@ function goBills() {
display: flex; display: flex;
align-items: center; align-items: center;
gap: $space-md; gap: $space-md;
@include fade-in-up(0.1s);
} }
.today-item { .today-item {
@@ -482,6 +504,7 @@ function goBills() {
gap: $space-md; gap: $space-md;
padding: 0 $space-xl; padding: 0 $space-xl;
margin-top: $space-xl; margin-top: $space-xl;
@include fade-in-up(0.15s);
} }
.quick-btn { .quick-btn {
@@ -492,9 +515,10 @@ function goBills() {
flex-direction: column; flex-direction: column;
@include flex-center; @include flex-center;
gap: $space-xs; gap: $space-xs;
transition: transform $transition-fast, box-shadow $transition-fast;
&:active { &:active {
transform: scale(0.97); transform: scale(0.94);
box-shadow: $shadow-clay-pressed; box-shadow: $shadow-clay-pressed;
} }
} }
@@ -621,4 +645,41 @@ function goBills() {
font-weight: 600; font-weight: 600;
color: #FF8C69; color: #FF8C69;
} }
/* 网络异常全屏提示 */
.network-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx $space-xl;
gap: $space-md;
}
.network-error-title {
font-size: $font-2xl;
font-weight: 600;
color: $text;
margin-top: $space-lg;
}
.network-error-desc {
font-size: $font-lg;
color: $text-sec;
}
.network-error-btn {
margin-top: $space-lg;
padding: $space-md $space-2xl;
background: $primary;
border-radius: $radius-xl;
&:active { opacity: 0.8; }
}
.network-error-btn-text {
font-size: $font-lg;
font-weight: 600;
color: $surface;
}
</style> </style>

View File

@@ -23,13 +23,23 @@
</view> </view>
<!-- 通知列表 --> <!-- 通知列表 -->
<view v-if="loading && list.length === 0" class="loading-box"> <view v-if="loading && list.length === 0" class="skeleton-list">
<text class="loading-text">加载中...</text> <view v-for="i in 4" :key="i" class="skeleton-notif">
<Skeleton width="64rpx" height="64rpx" variant="circle" />
<view class="skeleton-info">
<Skeleton width="240rpx" height="28rpx" variant="text" />
<Skeleton width="400rpx" height="24rpx" variant="text" />
<Skeleton width="120rpx" height="20rpx" variant="text" />
</view>
</view>
</view> </view>
<view v-else-if="list.length === 0" class="empty-box"> <view v-else-if="list.length === 0" class="empty-box">
<Icon name="bell" :size="48" color="#BFB3B3" /> <view class="empty-icon-wrap">
<Icon name="bell" :size="48" color="#BFB3B3" />
</view>
<text class="empty-text">暂无通知</text> <text class="empty-text">暂无通知</text>
<text class="empty-hint">新的通知会在这里显示</text>
</view> </view>
<view v-else class="notification-list"> <view v-else class="notification-list">
@@ -39,7 +49,7 @@
</view> </view>
<view class="notif-content"> <view class="notif-content">
<view class="notif-title-row"> <view class="notif-title-row">
<text v-if="item.is_pinned" class="pin-badge">📌</text> <text v-if="item.is_pinned" class="pin-badge">置顶</text>
<text class="notif-title">{{ item.title }}</text> <text class="notif-title">{{ item.title }}</text>
</view> </view>
<text class="notif-body" v-if="item.content">{{ item.content }}</text> <text class="notif-body" v-if="item.content">{{ item.content }}</text>
@@ -87,6 +97,7 @@ import { getNotifications, markRead, markAllRead, getNotificationImageUrl } from
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system' import { statusBarHeight, capsuleRight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
import type { Notification } from '@/api/notification' import type { Notification } from '@/api/notification'
const notifStore = useNotificationStore() const notifStore = useNotificationStore()
@@ -110,7 +121,7 @@ let loadSeq = 0
const noMore = computed(() => list.value.length >= total.value && total.value > 0) const noMore = computed(() => list.value.length >= total.value && total.value > 0)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
await loadNotifications(true) await loadNotifications(true)
initialLoaded.value = true initialLoaded.value = true
}) })
@@ -305,7 +316,20 @@ onReachBottom(() => {
} }
} }
.loading-box, .empty-box { // 骨架屏
.skeleton-list { padding: 0 $space-xl; }
.skeleton-notif {
display: flex; align-items: flex-start; gap: $space-lg;
padding: $space-lg; margin-bottom: $space-sm;
background: $surface; border-radius: $radius-xl;
border: 2rpx solid $border;
}
.skeleton-info {
flex: 1; display: flex; flex-direction: column; gap: $space-xs;
}
// 空状态
.empty-box {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -313,8 +337,20 @@ onReachBottom(() => {
padding: 120rpx 0; padding: 120rpx 0;
} }
.loading-text, .empty-text { .empty-icon-wrap {
font-size: $font-lg; width: 120rpx; height: 120rpx; border-radius: 50%;
background: $surface-warm; @include flex-center;
margin-bottom: $space-sm;
}
.empty-text {
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.empty-hint {
font-size: $font-md;
color: $text-muted; color: $text-muted;
} }
@@ -355,7 +391,12 @@ onReachBottom(() => {
} }
.pin-badge { .pin-badge {
font-size: $font-md; font-size: $font-xs;
color: $primary;
background: #FFE8E0;
padding: 2rpx 10rpx;
border-radius: $radius-xs;
font-weight: 500;
} }
.notif-title { .notif-title {
@@ -435,6 +476,7 @@ onReachBottom(() => {
border-radius: $radius-2xl $radius-2xl 0 0; border-radius: $radius-2xl $radius-2xl 0 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
} }
.detail-header { .detail-header {

View File

@@ -60,7 +60,7 @@ const uploading = ref(false)
const saving = ref(false) const saving = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
formNickname.value = userStore.nickname formNickname.value = userStore.nickname
formSlogan.value = userStore.slogan formSlogan.value = userStore.slogan
previewUrl.value = userStore.avatarUrl previewUrl.value = userStore.avatarUrl

View File

@@ -51,10 +51,22 @@
<text class="mi-label">分类管理</text> <text class="mi-label">分类管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" /> <Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view> </view>
<view class="menu-item" @tap="exportData"> <view class="menu-item" @tap="goTagManage">
<text class="mi-label">标签管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goRecurring">
<text class="mi-label">周期账单</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="openExportPanel">
<text class="mi-label">数据导出</text> <text class="mi-label">数据导出</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" /> <Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view> </view>
<view class="menu-item" @tap="goDataImport">
<text class="mi-label">数据导入</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view> </view>
<view class="menu-card mt"> <view class="menu-card mt">
@@ -79,6 +91,10 @@
<text class="mi-label">管理后台</text> <text class="mi-label">管理后台</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" /> <Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view> </view>
<view class="menu-item" @tap="goBackupManage">
<text class="mi-label">数据备份</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view> </view>
<!-- 关于弹窗 --> <!-- 关于弹窗 -->
@@ -94,6 +110,70 @@
</view> </view>
</view> </view>
<!-- 导出弹窗 -->
<view class="modal-mask" v-if="showExportPanel" @tap="showExportPanel = false">
<view class="export-modal" @tap.stop>
<text class="modal-title">数据导出</text>
<!-- 日期范围 -->
<view class="export-section">
<text class="export-label">日期范围</text>
<view class="export-date-row">
<picker mode="date" :value="exportStartDate" :end="exportEndDate" @change="e => { exportStartDate = e.detail.value; updateExportCount() }">
<view class="export-date-picker">
<Icon name="calendar" :size="24" color="#8B7E7E" />
<text class="export-date-text">{{ exportStartDate }}</text>
</view>
</picker>
<text class="export-date-sep"></text>
<picker mode="date" :value="exportEndDate" :start="exportStartDate" :end="getLocalDateStr()" @change="e => { exportEndDate = e.detail.value; updateExportCount() }">
<view class="export-date-picker">
<Icon name="calendar" :size="24" color="#8B7E7E" />
<text class="export-date-text">{{ exportEndDate }}</text>
</view>
</picker>
</view>
</view>
<!-- 类型筛选 -->
<view class="export-section">
<text class="export-label">交易类型</text>
<view class="export-tabs">
<view class="export-tab" :class="{ active: exportType === 'all' }" @tap="exportType = 'all'; updateExportCount()">全部</view>
<view class="export-tab" :class="{ active: exportType === 'expense' }" @tap="exportType = 'expense'; updateExportCount()">仅支出</view>
<view class="export-tab" :class="{ active: exportType === 'income' }" @tap="exportType = 'income'; updateExportCount()">仅收入</view>
</view>
</view>
<!-- 导出格式 -->
<view class="export-section">
<text class="export-label">导出格式</text>
<view class="export-tabs">
<view class="export-tab" :class="{ active: exportFormat === 'csv' }" @tap="exportFormat = 'csv'">CSV</view>
<view class="export-tab" :class="{ active: exportFormat === 'json' }" @tap="exportFormat = 'json'">JSON</view>
</view>
</view>
<!-- 导出方式 -->
<view class="export-section">
<text class="export-label">导出方式</text>
<view class="export-tabs">
<view class="export-tab" :class="{ active: exportMode === 'local' }" @tap="exportMode = 'local'">本地导出</view>
<view class="export-tab" :class="{ active: exportMode === 'server' }" @tap="exportMode = 'server'">服务端导出</view>
</view>
<text class="export-hint" v-if="exportMode === 'server'">服务端导出支持大数据量直接从服务器下载文件</text>
</view>
<!-- 预览 & 导出 -->
<view class="export-footer">
<text class="export-preview"> <text class="export-count">{{ exportCount }}</text> 条记录</text>
<view class="export-btn" :class="{ disabled: exportCount === 0 || exportLoading }" @tap="doExport">
<text class="export-btn-text">{{ exportLoading ? '导出中...' : '导出 ' + exportFormat.toUpperCase() }}</text>
</view>
</view>
</view>
</view>
<!-- 身份选择弹窗 --> <!-- 身份选择弹窗 -->
<view class="modal-mask" v-if="showIdentity" @tap="showIdentity = false"> <view class="modal-mask" v-if="showIdentity" @tap="showIdentity = false">
<view class="identity-modal" @tap.stop> <view class="identity-modal" @tap.stop>
@@ -144,6 +224,7 @@ import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction' import { getTransactions } from '@/api/transaction'
import type { Transaction } from '@/api/transaction'
import { formatAmount } from '@/utils/format' import { formatAmount } from '@/utils/format'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
@@ -162,8 +243,38 @@ const showAbout = ref(false)
const showIdentity = ref(false) const showIdentity = ref(false)
const initialLoaded = ref(false) const initialLoaded = ref(false)
// 导出面板状态
const showExportPanel = ref(false)
const exportStartDate = ref(getLocalDateStr().replace(/-/g, '-').replace(/(\d+)$/, '01')) // 本月1日
const exportEndDate = ref(getLocalDateStr())
const exportType = ref<'all' | 'expense' | 'income'>('all')
const exportFormat = ref<'csv' | 'json'>('csv')
const exportMode = ref<'local' | 'server'>('local')
const exportCount = ref(0)
const exportLoading = ref(false)
function openExportPanel() {
showExportPanel.value = true
updateExportCount()
}
async function updateExportCount() {
try {
const data = await getTransactions({
page: 1, pageSize: 1,
startDate: exportStartDate.value,
endDate: exportEndDate.value,
...(exportType.value !== 'all' ? { type: exportType.value } : {}),
group_id: groupStore.currentGroupId
})
exportCount.value = data.total
} catch {
exportCount.value = 0
}
}
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
loading.value = true loading.value = true
try { try {
await Promise.all([ await Promise.all([
@@ -217,6 +328,14 @@ function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' }) uni.navigateTo({ url: '/pages/category-manage/index' })
} }
function goTagManage() {
uni.navigateTo({ url: '/pages/tag-manage/index' })
}
function goRecurring() {
uni.navigateTo({ url: '/pages/recurring/index' })
}
function goPrivacy() { function goPrivacy() {
uni.navigateTo({ url: '/pages/privacy/index' }) uni.navigateTo({ url: '/pages/privacy/index' })
} }
@@ -225,6 +344,14 @@ function goFeedback() {
uni.navigateTo({ url: '/pages/feedback/index' }) uni.navigateTo({ url: '/pages/feedback/index' })
} }
function goDataImport() {
uni.navigateTo({ url: '/pages/data-import/index' })
}
function goBackupManage() {
uni.navigateTo({ url: '/pages/backup-manage/index' })
}
function goProfileEdit() { function goProfileEdit() {
uni.navigateTo({ url: '/pages/profile-edit/index' }) uni.navigateTo({ url: '/pages/profile-edit/index' })
} }
@@ -322,19 +449,71 @@ function getLocalDateStr(): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} }
async function exportData() { async function doExport() {
if (exportCount.value === 0 || exportLoading.value) return
// 服务端导出
if (exportMode.value === 'server') {
exportLoading.value = true
try {
const { getExportUrl } = await import('@/api/export')
const url = await getExportUrl({
startDate: exportStartDate.value,
endDate: exportEndDate.value,
type: exportType.value,
format: exportFormat.value
})
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifdef MP-WEIXIN
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode === 200) {
const ext = exportFormat.value === 'json' ? 'json' : 'csv'
uni.openDocument({
filePath: res.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => uni.showToast({ title: '打开失败', icon: 'none' })
})
}
},
fail: () => uni.showToast({ title: '下载失败', icon: 'none' })
})
// #endif
uni.showToast({ title: '导出成功', icon: 'success' })
} catch {
uni.showToast({ title: '导出失败', icon: 'none' })
} finally {
exportLoading.value = false
showExportPanel.value = false
}
return
}
// 本地导出
exportLoading.value = true
uni.showLoading({ title: '导出中 0%' }) uni.showLoading({ title: '导出中 0%' })
try { try {
// 分页获取全部数据(服务端 pageSize 上限 100 // 分页获取筛选后的数据
const allList: any[] = [] const allList: Transaction[] = []
let page = 1 let page = 1
let total = 0 let total = 0
const baseParams: Record<string, any> = {
pageSize: 100,
startDate: exportStartDate.value,
endDate: exportEndDate.value,
group_id: groupStore.currentGroupId
}
if (exportType.value !== 'all') baseParams.type = exportType.value
do { do {
const data = await getTransactions({ page, pageSize: 100, group_id: groupStore.currentGroupId }) const data = await getTransactions({ page, ...baseParams } as any)
allList.push(...data.list) allList.push(...data.list)
total = data.total total = data.total
page++ page++
// 更新进度
const progress = Math.min(100, Math.round((allList.length / total) * 100)) const progress = Math.min(100, Math.round((allList.length / total) * 100))
uni.showLoading({ title: `导出中 ${progress}%` }) uni.showLoading({ title: `导出中 ${progress}%` })
} while (allList.length < total) } while (allList.length < total)
@@ -345,50 +524,71 @@ async function exportData() {
return return
} }
function csvEscape(val: string): string { if (exportFormat.value === 'json') {
if (val.includes(',') || val.includes('"') || val.includes('\n')) { const json = JSON.stringify(allList.map(t => ({
return '"' + val.replace(/"/g, '""') + '"' date: t.date,
type: t.type === 'expense' ? '支出' : '收入',
category: t.category_name || '未分类',
amount: (t.amount / 100).toFixed(2),
note: t.note || ''
})), null, 2)
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.json`
saveFile(json, fileName, 'application/json')
} else {
function csvEscape(val: string): string {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"'
}
return val
} }
return val const header = '日期,类型,分类,金额(元),备注\n'
const rows = allList.map(t => {
const amount = (t.amount / 100).toFixed(2)
const type = t.type === 'expense' ? '支出' : '收入'
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
}).join('\n')
const csv = '' + header + rows
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.csv`
saveFile(csv, fileName, 'text/csv')
} }
const header = '日期,类型,分类,金额(元),备注\n'
const rows = allList.map(t => {
const amount = (t.amount / 100).toFixed(2)
const type = t.type === 'expense' ? '支出' : '收入'
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
}).join('\n')
const csv = '' + header + rows
// #ifdef H5
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `小菜记账_${getLocalDateStr()}.csv`
a.click()
URL.revokeObjectURL(url)
uni.hideLoading()
uni.showToast({ title: '导出成功', icon: 'success' })
// #endif
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${getLocalDateStr()}.csv`
fs.writeFileSync(filePath, csv, 'utf-8')
uni.hideLoading()
uni.openDocument({
filePath,
fileType: 'csv',
success: () => {},
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
})
// #endif
} catch { } catch {
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: '导出失败', icon: 'none' }) uni.showToast({ title: '导出失败', icon: 'none' })
} finally {
exportLoading.value = false
showExportPanel.value = false
} }
} }
function saveFile(content: string, fileName: string, mimeType: string) {
// #ifdef H5
const blob = new Blob([content], { type: `${mimeType};charset=utf-8;` })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
uni.hideLoading()
uni.showToast({ title: '导出成功', icon: 'success' })
// #endif
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`
fs.writeFileSync(filePath, content, 'utf-8')
uni.hideLoading()
const ext = mimeType === 'application/json' ? 'json' : 'csv'
uni.openDocument({
filePath,
fileType: ext,
success: () => {},
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
})
// #endif
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -725,4 +925,129 @@ async function exportData() {
font-size: $font-base; font-size: $font-base;
color: $text-sec; color: $text-sec;
} }
/* 导出弹窗 */
.export-modal {
width: 100%;
max-height: 85vh;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
padding: $space-xl;
padding-bottom: calc($space-xl + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
}
.export-section {
margin-bottom: $space-lg;
}
.export-label {
font-size: $font-base;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-sm;
}
.export-date-row {
display: flex;
align-items: center;
gap: $space-sm;
}
.export-date-picker {
flex: 1;
display: flex;
align-items: center;
gap: $space-xs;
height: 72rpx;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
}
.export-date-text {
font-size: $font-md;
color: $text;
}
.export-date-sep {
font-size: $font-md;
color: $text-muted;
flex-shrink: 0;
}
.export-tabs {
display: flex;
gap: $space-xs;
}
.export-tab {
padding: $space-sm $space-md;
border-radius: $radius-lg;
font-size: $font-md;
color: $text-sec;
background: $bg;
border: 2rpx solid $border;
transition: all $transition-normal;
&.active {
background: $primary;
color: $surface;
border-color: $primary;
}
}
.export-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: $space-md;
border-top: 2rpx solid $border;
}
.export-preview {
font-size: $font-lg;
color: $text-sec;
}
.export-count {
font-family: 'Fredoka', sans-serif;
font-size: $font-2xl;
font-weight: 600;
color: $primary;
}
.export-btn {
height: 80rpx;
padding: 0 $space-xl;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
gap: $space-xs;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&:active { opacity: 0.8; }
}
.export-btn-text {
font-size: $font-lg;
font-weight: 600;
color: $surface;
}
.export-hint {
font-size: $font-sm;
color: $text-muted;
display: block;
margin-top: $space-sm;
}
</style> </style>

View File

@@ -0,0 +1,545 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar" :style="{ paddingRight: capsuleRight + 'px' }">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
</view>
<text class="nav-title">周期账单</text>
<view class="nav-action" @tap="openAddModal">
<Icon name="plus" :size="40" color="#FF8C69" />
</view>
</view>
</view>
<!-- 同步提示 -->
<view class="sync-bar" v-if="syncCount > 0" @tap="syncCount = 0">
<Icon name="check" :size="28" color="#43A047" />
<text class="sync-text">已自动生成 {{ syncCount }} 笔记录</text>
</view>
<!-- 加载//列表 -->
<view v-if="loading" class="skeleton-list">
<view v-for="i in 3" :key="i" class="skeleton-card">
<Skeleton width="88rpx" height="88rpx" variant="circle" />
<view class="skeleton-info">
<Skeleton width="200rpx" height="28rpx" variant="text" />
<Skeleton width="300rpx" height="24rpx" variant="text" />
</view>
<Skeleton width="120rpx" height="32rpx" variant="rect" />
</view>
</view>
<view v-else-if="list.length === 0" class="empty-box">
<view class="empty-icon-wrap">
<Icon name="edit" :size="48" color="#BFB3B3" />
</view>
<text class="empty-text">暂无周期账单</text>
<text class="empty-hint">设置房租订阅工资等定期收支</text>
<view class="empty-btn" @tap="openAddModal">
<Icon name="plus" :size="24" color="#FFFFFF" />
<text class="empty-btn-text">添加周期账单</text>
</view>
</view>
<scroll-view v-else class="list" scroll-y>
<view v-for="item in list" :key="item.id" class="template-card" :class="{ inactive: !item.is_active }">
<view class="card-main" @tap="openEditModal(item)">
<CategoryIcon :label="(item.category_name || '?')[0]" :color="item.category_color || '#FF8C69'" :bg-color="(item.category_color || '#FF8C69') + '15'" size="md" />
<view class="card-info">
<view class="card-title-row">
<text class="card-name">{{ item.category_name || '未分类' }}</text>
<text class="card-frequency">{{ freqMap[item.frequency] }}</text>
</view>
<text class="card-note" v-if="item.note">{{ item.note }}</text>
<text class="card-date">下次{{ item.next_date }} {{ item.end_date ? '· 至 ' + item.end_date : '' }}</text>
</view>
<text class="card-amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
</view>
<view class="card-actions">
<view class="card-switch" @tap="toggleActive(item)">
<view class="switch-track" :class="{ on: item.is_active }">
<view class="switch-thumb"></view>
</view>
<text class="switch-label">{{ item.is_active ? '开启' : '暂停' }}</text>
</view>
<view class="card-btn" @tap="openEditModal(item)">
<Icon name="edit" :size="24" color="#8B7E7E" />
</view>
<view class="card-btn" @tap="handleDelete(item)">
<Icon name="trash" :size="24" color="#FF6B6B" />
</view>
</view>
</view>
</scroll-view>
<!-- 添加/编辑弹窗 -->
<view class="modal-mask" v-if="showModal" @tap="closeModal">
<view class="form-modal" @tap.stop>
<view class="modal-header">
<text class="modal-title">{{ editingId ? '编辑周期账单' : '添加周期账单' }}</text>
<view class="modal-close" @tap="closeModal">
<text class="modal-close-text">×</text>
</view>
</view>
<scroll-view class="modal-body" scroll-y>
<!-- 类型切换 -->
<view class="type-toggle-wrap">
<view class="type-toggle" :class="{ income: form.type === 'income' }">
<view class="slider"></view>
<view class="tab" :class="{ active: form.type === 'expense' }" @tap="form.type = 'expense'">支出</view>
<view class="tab" :class="{ active: form.type === 'income' }" @tap="form.type = 'income'">收入</view>
</view>
</view>
<!-- 金额 -->
<AmountEditor
ref="amountEditorRef"
v-model="amountStr"
:fixed="false"
size="medium"
:auto-focus="showModal"
@confirm="handleSubmit"
/>
<!-- 分类 -->
<scroll-view class="category-scroll" scroll-x @tap="blurAmountEditor">
<view class="category-row">
<view v-for="cat in currentCategories" :key="cat.id" class="cat-item" @tap="form.category_id = cat.id">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" :selected="form.category_id === cat.id" />
<text class="cat-name" :class="{ active: form.category_id === cat.id }">{{ cat.name }}</text>
</view>
</view>
</scroll-view>
<!-- 备注 -->
<view class="form-item">
<Icon name="edit" :size="28" color="#8B7E7E" />
<input class="form-input" v-model="form.note" placeholder="备注(可选)" maxlength="50" @focus="blurAmountEditor" />
</view>
<!-- 周期 -->
<view class="form-item">
<Icon name="calendar" :size="28" color="#8B7E7E" />
<view class="freq-options">
<view v-for="f in frequencies" :key="f.value" class="freq-tab" :class="{ active: form.frequency === f.value }" @tap="selectFrequency(f.value)">
<text class="freq-text">{{ f.label }}</text>
</view>
</view>
</view>
<!-- 开始日期 -->
<view class="form-item">
<text class="form-label">开始日期</text>
<picker mode="date" :value="form.next_date" @change="e => form.next_date = e.detail.value">
<view class="form-picker">
<text class="form-picker-text">{{ form.next_date }}</text>
</view>
</picker>
</view>
<!-- 结束日期 -->
<view class="form-item">
<text class="form-label">结束日期</text>
<picker mode="date" :value="form.end_date || ''" :start="form.next_date" @change="e => form.end_date = e.detail.value">
<view class="form-picker">
<text class="form-picker-text" :class="{ placeholder: !form.end_date }">{{ form.end_date || '永久(不自动结束)' }}</text>
</view>
</picker>
</view>
</scroll-view>
<view class="modal-footer" @tap="handleSubmit">
<text class="submit-text">{{ editingId ? '保存修改' : '添加' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import { formatAmount } from '@/utils/format'
import { useCategoryStore } from '@/stores/category'
import { getRecurringTemplates, createRecurringTemplate, updateRecurringTemplate, deleteRecurringTemplate, syncRecurring } from '@/api/recurring'
import Icon from '@/components/Icon/Icon.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
import type { RecurringTemplate } from '@/api/recurring'
const catStore = useCategoryStore()
const list = ref<RecurringTemplate[]>([])
const loading = ref(true)
const showModal = ref(false)
const editingId = ref<number | null>(null)
const amountStr = ref('')
const saving = ref(false)
const syncCount = ref(0)
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const freqMap: Record<string, string> = {
weekly: '每周', monthly: '每月', yearly: '每年'
}
const frequencies = [
{ value: 'monthly' as const, label: '每月' },
{ value: 'weekly' as const, label: '每周' },
{ value: 'yearly' as const, label: '每年' },
]
const defaultForm = {
type: 'expense' as 'expense' | 'income',
category_id: 0,
note: '',
frequency: 'monthly' as 'weekly' | 'monthly' | 'yearly',
next_date: getTodayStr(),
end_date: '' as string,
}
const form = ref({ ...defaultForm })
const currentCategories = computed(() => catStore.getByType(form.value.type))
function getTodayStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
onMounted(async () => {
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await Promise.all([loadList(), syncAndLoad()])
})
async function loadList() {
loading.value = true
try {
list.value = await getRecurringTemplates()
} catch { } finally {
loading.value = false
}
}
async function syncAndLoad() {
try {
// 同步到期账单(静默,失败不影响主流程)
const result = await syncRecurring()
if (result.generated > 0) {
syncCount.value = result.generated
setTimeout(() => { syncCount.value = 0 }, 5000)
}
} catch { }
}
function openAddModal() {
editingId.value = null
form.value = { ...defaultForm, next_date: getTodayStr() }
const cats = catStore.getByType('expense')
if (cats.length > 0) form.value.category_id = cats[0].id
amountStr.value = ''
showModal.value = true
}
function openEditModal(item: RecurringTemplate) {
editingId.value = item.id
form.value = {
type: item.type,
category_id: item.category_id,
note: item.note,
frequency: item.frequency,
next_date: item.next_date,
end_date: item.end_date || '',
}
amountStr.value = (item.amount / 100).toString()
showModal.value = true
}
async function toggleActive(item: RecurringTemplate) {
const newState = item.is_active ? 0 : 1
try {
await updateRecurringTemplate(item.id, { is_active: newState })
item.is_active = newState
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
async function handleSubmit() {
if (saving.value) return
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
if (isNaN(amount) || amount <= 0) {
uni.showToast({ title: '请输入金额', icon: 'none' })
return
}
if (!form.value.category_id) {
uni.showToast({ title: '请选择分类', icon: 'none' })
return
}
saving.value = true
try {
const data = {
amount,
type: form.value.type,
category_id: form.value.category_id,
note: form.value.note,
frequency: form.value.frequency,
next_date: form.value.next_date,
end_date: form.value.end_date || undefined,
}
if (editingId.value) {
await updateRecurringTemplate(editingId.value, data)
} else {
await createRecurringTemplate(data)
}
closeModal()
await loadList()
uni.showToast({ title: editingId.value ? '已更新' : '已添加', icon: 'success' })
} catch {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
async function handleDelete(item: RecurringTemplate) {
uni.showModal({
title: '删除周期账单',
content: `确定删除「${item.category_name || ''} ${formatAmount(item.amount)}」?已生成的记录不受影响。`,
success: async (res) => {
if (res.confirm) {
try {
await deleteRecurringTemplate(item.id)
list.value = list.value.filter(t => t.id !== item.id)
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
function closeModal() {
amountEditorRef.value?.blur()
showModal.value = false
}
function blurAmountEditor() {
amountEditorRef.value?.blur()
}
function selectFrequency(value: 'weekly' | 'monthly' | 'yearly') {
blurAmountEditor()
form.value.frequency = value
}
function goBack() { uni.navigateBack() }
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page { min-height: 100vh; background: $bg; padding-bottom: 120rpx; }
.header-fixed { @include sticky-header; }
.status-bar { @include status-bar; }
.nav-bar {
display: flex; align-items: center;
padding: $space-sm $space-xl $space-md;
}
.nav-back { @include nav-back; }
.nav-title {
flex: 1; text-align: center;
font-size: $font-2xl; font-weight: 600; color: $text;
}
.nav-action {
width: 88rpx; height: 88rpx; @include flex-center;
&:active { opacity: 0.6; }
}
.sync-bar {
display: flex; align-items: center; gap: $space-xs;
margin: $space-sm $space-xl; padding: $space-sm $space-md;
background: #E8F5E9; border-radius: $radius-lg;
&:active { opacity: 0.8; }
}
.sync-text { font-size: $font-md; color: #43A047; font-weight: 500; }
// 骨架屏
.skeleton-list { padding: 0 $space-xl; }
.skeleton-card {
display: flex; align-items: center; gap: $space-md;
padding: $space-lg; margin-bottom: $space-sm;
background: $surface; border-radius: $radius-xl;
border: 2rpx solid $border;
}
.skeleton-info {
flex: 1; display: flex; flex-direction: column; gap: $space-xs;
}
// 空状态
.empty-box {
display: flex; flex-direction: column; align-items: center;
padding: 120rpx 0; gap: $space-sm;
}
.empty-icon-wrap {
width: 120rpx; height: 120rpx; border-radius: 50%;
background: $surface-warm; @include flex-center;
margin-bottom: $space-sm;
}
.empty-text { font-size: $font-xl; font-weight: 600; color: $text; }
.empty-hint { font-size: $font-md; color: $text-muted; margin-top: -$space-xs; }
.empty-btn {
margin-top: $space-lg; padding: $space-sm $space-xl;
background: linear-gradient(135deg, $primary, $primary-dark);
border-radius: $radius-lg; display: flex; align-items: center; gap: $space-xs;
&:active { opacity: 0.8; transform: scale(0.96); }
}
.empty-btn-text { font-size: $font-base; color: $surface; font-weight: 600; }
.list { padding: 0 $space-xl; max-height: calc(100vh - 200rpx); }
.template-card {
background: $surface; border-radius: $radius-xl;
border: 2rpx solid $border; box-shadow: $shadow-md;
padding: $space-lg; margin-bottom: $space-sm;
&.inactive { opacity: 0.6; }
}
.card-main {
display: flex; align-items: center; gap: $space-md;
&:active { opacity: 0.7; }
}
.card-info { flex: 1; min-width: 0; }
.card-title-row { display: flex; align-items: center; gap: $space-sm; }
.card-name { font-size: $font-lg; font-weight: 600; color: $text; }
.card-frequency {
font-size: $font-xs; color: $primary; background: $primary-light;
padding: 2rpx 10rpx; border-radius: $radius-xs;
}
.card-note {
font-size: $font-sm; color: $text-sec;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
margin-top: 2rpx;
}
.card-date { font-size: $font-sm; color: $text-muted; margin-top: 2rpx; display: block; }
.card-amount {
font-family: 'Fredoka', sans-serif; font-size: $font-xl; font-weight: 600;
&.expense { color: $text; } &.income { color: $success; }
}
.card-actions {
display: flex; align-items: center; gap: $space-md;
margin-top: $space-sm; padding-top: $space-sm;
border-top: 2rpx solid $border;
}
.card-switch {
display: flex; align-items: center; gap: $space-sm;
flex: 1;
}
.switch-track {
width: 44rpx; height: 24rpx; border-radius: 12rpx;
background: #D0C4C4; position: relative; transition: background $transition-normal;
&.on { background: $primary; }
}
.switch-thumb {
position: absolute; top: 2rpx; left: 2rpx;
width: 20rpx; height: 20rpx; border-radius: 50%;
background: $surface; transition: transform $transition-normal;
.on & { transform: translateX(20rpx); }
}
.switch-label { font-size: $font-sm; color: $text-muted; }
.card-btn {
width: 64rpx; height: 64rpx; @include flex-center; border-radius: 50%;
&:active { background: $bg; }
}
/* 表单弹窗 */
.modal-mask { @include modal-mask; }
.form-modal {
width: 100%; max-height: 90vh; background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex; flex-direction: column; margin-top: auto;
padding-bottom: env(safe-area-inset-bottom);
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: $space-lg $space-xl $space-md; border-bottom: 2rpx solid $border;
}
.modal-title { font-size: $font-xl; font-weight: 600; color: $text; }
.modal-close {
width: 56rpx; height: 56rpx; @include flex-center; border-radius: 50%; background: $border;
&:active { background: darken($border, 5%); }
}
.modal-close-text { font-size: $font-2xl; color: $text-sec; line-height: 1; }
.modal-body { flex: 1; padding: $space-md $space-xl; max-height: 45vh; }
.modal-footer {
padding: $space-md $space-xl; border-top: 2rpx solid $border;
}
.submit-text {
display: block; text-align: center; font-size: 30rpx; font-weight: 600; color: $surface;
padding: $space-lg; background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-lg;
&:active { opacity: 0.8; }
}
/* 类型切换 */
.type-toggle-wrap { display: flex; justify-content: center; margin-bottom: $space-md; }
.type-toggle {
display: inline-flex; background: $surface-warm; border-radius: $radius-lg;
padding: $space-xs; position: relative;
}
.tab {
width: 140rpx; height: 64rpx; display: flex; align-items: center; justify-content: center;
font-size: $font-md; color: $text-sec; border-radius: $radius-md; position: relative; z-index: 1;
&.active { color: $primary; font-weight: 600; }
}
.slider {
position: absolute; top: $space-xs; left: $space-xs; width: 140rpx; height: 64rpx;
background: $surface; border-radius: $radius-md; box-shadow: $shadow-sm;
transition: transform $transition-normal;
}
.income .slider { transform: translateX(140rpx); }
/* 分类 */
.category-scroll { margin-bottom: $space-md; }
.category-row { display: flex; gap: $space-sm; padding: $space-xs 0; }
.cat-item {
display: flex; flex-direction: column; align-items: center; gap: 6rpx;
padding: 8rpx 12rpx; min-width: 100rpx;
&:active { opacity: 0.7; }
}
.cat-name { font-size: $font-sm; color: $text-sec; &.active { color: $primary; font-weight: 600; } }
/* 表单项 */
.form-item {
display: flex; align-items: center; gap: $space-sm;
height: 80rpx; margin-bottom: $space-sm;
padding: 0 $space-md; background: $bg; border-radius: $radius-lg;
border: 2rpx solid $border;
}
.form-label { font-size: $font-md; color: $text-sec; flex-shrink: 0; margin-right: $space-sm; }
.form-input { flex: 1; font-size: $font-md; color: $text; }
.form-picker { flex: 1; }
.form-picker-text { font-size: $font-md; color: $text; &.placeholder { color: $text-muted; } }
.freq-options { display: flex; gap: $space-xs; }
.freq-tab {
padding: $space-xs $space-lg; border-radius: $radius-md;
font-size: $font-md; color: $text-sec; background: $surface;
border: 2rpx solid $border;
&.active { background: $primary; color: $surface; border-color: $primary; }
&:active { opacity: 0.8; }
}
</style>

View File

@@ -22,6 +22,14 @@
</view> </view>
</view> </view>
<view class="period-wrap">
<view class="period-tabs">
<view class="period-tab" :class="{ active: period === 'week' }" @tap="switchPeriod('week')"></view>
<view class="period-tab" :class="{ active: period === 'month' }" @tap="switchPeriod('month')"></view>
<view class="period-tab" :class="{ active: period === 'year' }" @tap="switchPeriod('year')"></view>
</view>
</view>
<view class="overview-card"> <view class="overview-card">
<view class="ov-item"> <view class="ov-item">
<text class="ov-label">{{ tabType === 'expense' ? '总支出' : '总收入' }}</text> <text class="ov-label">{{ tabType === 'expense' ? '总支出' : '总收入' }}</text>
@@ -95,7 +103,7 @@
/> />
<view class="trend-list"> <view class="trend-list">
<view v-for="point in trendData" :key="point.date" class="trend-item"> <view v-for="point in trendData" :key="point.date" class="trend-item">
<text class="trend-date">{{ point.date.slice(8) }}</text> <text class="trend-date">{{ point.label || (point.date.slice(8) + '日') }}</text>
<view class="trend-bar-bg"> <view class="trend-bar-bg">
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view> <view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
</view> </view>
@@ -139,6 +147,7 @@ const configStore = useConfigStore()
const currentMonth = ref(getCurrentMonth()) const currentMonth = ref(getCurrentMonth())
const tabType = ref<'expense' | 'income'>('expense') const tabType = ref<'expense' | 'income'>('expense')
const period = ref<'week' | 'month' | 'year'>('month')
const { overview, categoryStats, trendData } = storeToRefs(statsStore) const { overview, categoryStats, trendData } = storeToRefs(statsStore)
const loading = ref(true) const loading = ref(true)
const loadError = ref(false) const loadError = ref(false)
@@ -176,7 +185,7 @@ const categoryChartData = computed(() => {
const trendChartData = computed(() => { const trendChartData = computed(() => {
if (trendData.value.length === 0) return null if (trendData.value.length === 0) return null
return { return {
categories: trendData.value.map(p => p.date.slice(8) + '日'), categories: trendData.value.map(p => p.label || (p.date.slice(8) + '日')),
series: [{ series: [{
name: tabType.value === 'expense' ? '支出' : '收入', name: tabType.value === 'expense' ? '支出' : '收入',
data: trendData.value.map(p => p.amount / 100) data: trendData.value.map(p => p.amount / 100)
@@ -229,7 +238,7 @@ const monthCompareText = computed(() => {
const initialLoaded = ref(false) const initialLoaded = ref(false)
onMounted(async () => { onMounted(async () => {
await waitForReady() try { await waitForReady() } catch { /* 超时,继续加载 */ }
loadData().finally(() => { initialLoaded.value = true }) loadData().finally(() => { initialLoaded.value = true })
}) })
@@ -241,16 +250,17 @@ onShow(() => {
watch(currentMonth, () => loadData()) watch(currentMonth, () => loadData())
// tab 切换时只请求分类和趋势数据 // tab 切换时只请求分类和趋势数据
watch(tabType, () => loadTabData()) watch(tabType, () => loadTabData())
// 周期切换时重新加载数据
watch(period, () => loadData())
async function loadData(silent = false) { async function loadData(silent = false) {
const seq = ++loadSeq const seq = ++loadSeq
try { try {
if (!silent) loading.value = true if (!silent) loading.value = true
loadError.value = false loadError.value = false
// 使用聚合接口一次获取 overview + category + trend再并行请求额外数据
await Promise.all([ await Promise.all([
statsStore.fetchOverview(currentMonth.value), statsStore.fetchDashboard(currentMonth.value, tabType.value, period.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value),
fetchMaxSingle(), fetchMaxSingle(),
fetchPrevMonthData() fetchPrevMonthData()
]) ])
@@ -268,11 +278,8 @@ async function loadData(silent = false) {
async function loadTabData() { async function loadTabData() {
const seq = ++loadSeq const seq = ++loadSeq
try { try {
await Promise.all([ await statsStore.fetchDashboard(currentMonth.value, tabType.value, period.value)
statsStore.fetchCategoryStats(currentMonth.value, tabType.value), await fetchMaxSingle()
statsStore.fetchTrend(currentMonth.value, tabType.value),
fetchMaxSingle()
])
if (seq !== loadSeq) return if (seq !== loadSeq) return
} catch (e) { } catch (e) {
if (seq !== loadSeq) return if (seq !== loadSeq) return
@@ -339,6 +346,10 @@ function nextMonth() {
currentMonth.value = m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}` currentMonth.value = m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`
} }
function switchPeriod(p: 'week' | 'month' | 'year') {
period.value = p
}
function getBarWidth(amount: number) { function getBarWidth(amount: number) {
const max = Math.max(...trendData.value.map(t => t.amount), 1) const max = Math.max(...trendData.value.map(t => t.amount), 1)
return (amount / max) * 100 return (amount / max) * 100
@@ -409,6 +420,35 @@ function getBarWidth(amount: number) {
} }
} }
.period-wrap {
display: flex;
justify-content: center;
margin-bottom: $space-lg;
}
.period-tabs {
display: inline-flex;
background: $surface-warm;
border-radius: $radius-lg;
padding: $space-xs;
gap: $space-xs;
}
.period-tab {
padding: $space-xs $space-lg;
border-radius: $radius-lg;
font-size: $font-md;
color: $text-sec;
transition: all $transition-normal;
&.active {
background: $surface;
color: $primary;
font-weight: 600;
box-shadow: $shadow-sm;
}
}
.overview-card, .chart-card { .overview-card, .chart-card {
@include card($radius-2xl); @include card($radius-2xl);
margin: 0 $space-xl $space-lg; margin: 0 $space-xl $space-lg;
@@ -548,6 +588,10 @@ function getBarWidth(amount: number) {
border-radius: 6rpx; border-radius: 6rpx;
background: linear-gradient(90deg, $primary, #FFB89A); background: linear-gradient(90deg, $primary, #FFB89A);
transition: width 0.6s ease-out; transition: width 0.6s ease-out;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
} }
.trend-amount { .trend-amount {

View File

@@ -0,0 +1,267 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="nav-title">标签管理</text>
<view class="nav-placeholder"></view>
</view>
</view>
<view class="tag-list" v-if="tagStore.tags.length > 0">
<view v-for="tag in tagStore.tags" :key="tag.id" class="tag-row">
<view class="tag-info" @tap="onEdit(tag)">
<view class="tag-dot" :style="{ background: tag.color }"></view>
<text class="tag-name">{{ tag.name }}</text>
</view>
<view class="tag-actions">
<view class="action-btn" @tap="onEdit(tag)">
<Icon name="edit" :size="24" color="#8B7E7E" />
</view>
<view class="action-btn" @tap="onDelete(tag)">
<Icon name="trash" :size="24" color="#FF6B6B" />
</view>
</view>
</view>
</view>
<view class="empty" v-else-if="!tagStore.loading">
<Icon name="tag" :size="48" color="#BFB3B3" />
<text class="empty-text">暂无标签点击下方按钮添加</text>
</view>
<!-- 浮动添加按钮 -->
<view class="fab" @tap="showAddModal = true">
<Icon name="plus" :size="48" color="#FFFFFF" />
</view>
<!-- 添加弹窗 -->
<EditModal
v-model:visible="showAddModal"
title="添加标签"
:fields="addFields"
:modelValue="addModel"
@confirm="onAdd"
/>
<!-- 编辑弹窗 -->
<EditModal
v-model:visible="showEditModal"
title="编辑标签"
:fields="editFields"
:modelValue="editModel"
@confirm="onEditConfirm"
/>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTagStore } from '@/stores/tag'
import type { Tag } from '@/api/tag'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
import EditModal from '@/components/EditModal/EditModal.vue'
import type { EditField } from '@/components/EditModal/EditModal.vue'
const tagStore = useTagStore()
const showAddModal = ref(false)
const showEditModal = ref(false)
const editingTag = ref<Tag | null>(null)
/** 添加/编辑弹窗字段配置 */
const addFields: EditField[] = [
{ key: 'name', label: '标签名称', type: 'text', placeholder: '标签名称', maxlength: 20 },
{ key: 'color', label: '颜色', type: 'color' },
]
const editFields: EditField[] = addFields
const addModel = ref({ name: '', color: '#FF8C69' })
const editModel = ref({ name: '', color: '#FF8C69' })
const initialLoaded = ref(false)
onMounted(async () => {
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await tagStore.fetchTags()
initialLoaded.value = true
})
onShow(() => {
if (initialLoaded.value) tagStore.fetchTags()
})
onPullDownRefresh(async () => {
await tagStore.fetchTags()
uni.stopPullDownRefresh()
})
function goBack() {
uni.navigateBack()
}
async function onAdd(data: Record<string, any>) {
const name = (data.name || '').toString().trim()
if (!name) return
try {
await tagStore.addTag({ name, color: data.color })
addModel.value = { name: '', color: '#FF8C69' }
showAddModal.value = false
uni.showToast({ title: '添加成功', icon: 'success' })
} catch {
uni.showToast({ title: '添加失败', icon: 'none' })
}
}
function onEdit(tag: Tag) {
editingTag.value = tag
editModel.value = { name: tag.name, color: tag.color }
showEditModal.value = true
}
async function onEditConfirm(data: Record<string, any>) {
if (!editingTag.value) return
const name = (data.name || '').toString().trim()
if (!name) {
uni.showToast({ title: '请输入名称', icon: 'none' })
return
}
try {
await tagStore.updateTag(editingTag.value.id, { name, color: data.color })
showEditModal.value = false
uni.showToast({ title: '修改成功', icon: 'success' })
} catch {
uni.showToast({ title: '修改失败', icon: 'none' })
}
}
async function onDelete(tag: Tag) {
uni.showModal({
title: '删除标签',
content: `确定删除「${tag.name}」?关联的交易记录不会被删除。`,
success: async (res) => {
if (res.confirm) {
try {
await tagStore.deleteTag(tag.id)
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
@include page-base;
}
.header-fixed {
@include sticky-header;
}
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: $space-sm $space-lg;
}
.nav-back {
@include nav-back;
border-radius: 50%;
&:active { background: $border; }
}
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
.nav-placeholder { width: 72rpx; }
.tag-list { padding: 0 $space-xl; }
.tag-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-md 0;
border-bottom: 1rpx solid $border;
&:last-child { border-bottom: none; }
}
.tag-info {
display: flex;
align-items: center;
gap: $space-sm;
flex: 1;
padding: $space-xs 0;
&:active { opacity: 0.6; }
}
.tag-dot {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
flex-shrink: 0;
}
.tag-name { font-size: $font-lg; color: $text; font-weight: 500; }
.tag-actions {
display: flex;
gap: $space-xs;
}
.action-btn {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&:active { background: $border; }
}
.empty {
@include state-box;
padding: 120rpx 0;
}
.empty-text { @include state-text; }
.fab {
position: fixed;
right: $space-xl;
bottom: calc(120rpx + env(safe-area-inset-bottom));
width: 112rpx;
height: 112rpx;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 8rpx 8rpx 24rpx rgba(255, 140, 105, 0.4);
z-index: 50;
&:active { transform: scale(0.95); }
}
</style>

View File

@@ -13,7 +13,8 @@ export const useBudgetStore = defineStore('budget', () => {
} }
async function setBudget(amount: number, month?: string) { async function setBudget(amount: number, month?: string) {
await api.setBudget(amount, month || getCurrentMonth()) const groupStore = useGroupStore()
await api.setBudget(amount, month || getCurrentMonth(), groupStore.currentGroupId)
await fetchBudget(month) await fetchBudget(month)
} }

View File

@@ -7,9 +7,20 @@ export type Category = api.Category
export const useCategoryStore = defineStore('category', () => { export const useCategoryStore = defineStore('category', () => {
const categories = ref<api.Category[]>([]) const categories = ref<api.Category[]>([])
/** 上次成功获取的时间戳ms用于 5 分钟缓存判断 */
const lastFetchTime = ref(0)
const CACHE_TTL = 5 * 60 * 1000 // 5 分钟
async function fetchCategories() { async function fetchCategories(force = false) {
categories.value = await api.getCategories() if (!force && lastFetchTime.value && Date.now() - lastFetchTime.value < CACHE_TTL) {
return // 缓存有效,跳过
}
try {
categories.value = await api.getCategories()
lastFetchTime.value = Date.now()
} catch {
// 保留上次数据
}
} }
function getByType(type: 'expense' | 'income') { function getByType(type: 'expense' | 'income') {
@@ -20,31 +31,79 @@ export const useCategoryStore = defineStore('category', () => {
return categories.value.find(c => c.id === id) return categories.value.find(c => c.id === id)
} }
/** 乐观新增 */
async function addCategory(data: { name: string; icon: string; color: string; type: 'expense' | 'income' }) { async function addCategory(data: { name: string; icon: string; color: string; type: 'expense' | 'income' }) {
const result = await api.createCategory(data) const tempId = -Date.now()
await fetchCategories() const optimistic: api.Category = { id: tempId, ...data, is_custom: 1, sort_order: 0 }
return result.id categories.value.push(optimistic)
lastFetchTime.value = 0 // 使缓存失效(新增后下次 onShow 会刷新)
try {
const result = await api.createCategory(data)
// 用真实 ID 替换临时 ID
const idx = categories.value.findIndex(c => c.id === tempId)
if (idx !== -1) categories.value[idx].id = result.id
return result.id
} catch (err) {
// 回滚
categories.value = categories.value.filter(c => c.id !== tempId)
uni.showToast({ title: '添加失败', icon: 'none' })
throw err
}
} }
/** 乐观编辑 */
async function updateCategory(id: number, data: { name: string; color: string }) { async function updateCategory(id: number, data: { name: string; color: string }) {
await api.updateCategory(id, data) const idx = categories.value.findIndex(c => c.id === id)
await fetchCategories() if (idx === -1) return
const backup = { ...categories.value[idx] }
Object.assign(categories.value[idx], data)
lastFetchTime.value = 0
try {
await api.updateCategory(id, data)
} catch (err) {
// 回滚
categories.value[idx] = backup
uni.showToast({ title: '修改失败', icon: 'none' })
throw err
}
} }
async function deleteCategory(id: number) { async function deleteCategory(id: number) {
// 删除操作等 API 确认(防误删)
await api.deleteCategory(id) await api.deleteCategory(id)
await fetchCategories() categories.value = categories.value.filter(c => c.id !== id)
lastFetchTime.value = 0
} }
async function migrateCategory(fromId: number, toId: number) { async function migrateCategory(fromId: number, toId: number) {
await api.migrateCategory(fromId, toId) await api.migrateCategory(fromId, toId)
await fetchCategories() categories.value = categories.value.filter(c => c.id !== fromId)
lastFetchTime.value = 0
} }
/** 乐观排序 */
async function sortCategories(ids: number[]) { async function sortCategories(ids: number[]) {
await api.sortCategories(ids) const backup = [...categories.value]
await fetchCategories() // 按新顺序重排
const sorted: api.Category[] = []
ids.forEach(id => {
const cat = categories.value.find(c => c.id === id)
if (cat) sorted.push(cat)
})
// 保留未参与排序的分类(不同 type 的)
categories.value.forEach(c => {
if (!ids.includes(c.id)) sorted.push(c)
})
categories.value = sorted
try {
await api.sortCategories(ids)
} catch (err) {
// 回滚
categories.value = backup
uni.showToast({ title: '排序失败', icon: 'none' })
throw err
}
} }
return { categories, fetchCategories, getByType, getById, addCategory, updateCategory, deleteCategory, migrateCategory, sortCategories } return { categories, lastFetchTime, fetchCategories, getByType, getById, addCategory, updateCategory, deleteCategory, migrateCategory, sortCategories }
}) })

View File

@@ -35,14 +35,17 @@ export const useGroupStore = defineStore('group', () => {
await refreshAll() await refreshAll()
} }
/** 切换身份后全局刷新所有数据(串行执行避免数据错乱 */ /** 切换身份后全局刷新所有数据(无依赖关系的可并行 */
async function refreshAll() { async function refreshAll() {
const statsStore = useStatsStore() const statsStore = useStatsStore()
const budgetStore = useBudgetStore() const budgetStore = useBudgetStore()
const txStore = useTransactionStore() const txStore = useTransactionStore()
await statsStore.fetchOverview() // stats、budget、transactions 互相独立,可以并行
await budgetStore.fetchBudget() await Promise.all([
await txStore.fetchTransactions({ page: 1 }) statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1 })
])
} }
/** 获取群组列表 */ /** 获取群组列表 */
@@ -55,7 +58,7 @@ export const useGroupStore = defineStore('group', () => {
if (currentGroupId.value !== null) { if (currentGroupId.value !== null) {
const stillValid = groups.value.some(g => g.id === currentGroupId.value) const stillValid = groups.value.some(g => g.id === currentGroupId.value)
if (!stillValid) { if (!stillValid) {
switchToPersonal() await switchToPersonal()
} }
} }
} catch { } catch {
@@ -83,7 +86,7 @@ export const useGroupStore = defineStore('group', () => {
async function leaveGroup(groupId: number) { async function leaveGroup(groupId: number) {
await api.leaveGroup(groupId) await api.leaveGroup(groupId)
if (currentGroupId.value === groupId) { if (currentGroupId.value === groupId) {
switchToPersonal() await switchToPersonal()
} }
await fetchGroups() await fetchGroups()
} }
@@ -92,7 +95,7 @@ export const useGroupStore = defineStore('group', () => {
async function deleteGroup(groupId: number) { async function deleteGroup(groupId: number) {
await api.deleteGroup(groupId) await api.deleteGroup(groupId)
if (currentGroupId.value === groupId) { if (currentGroupId.value === groupId) {
switchToPersonal() await switchToPersonal()
} }
await fetchGroups() await fetchGroups()
} }

View File

@@ -6,11 +6,18 @@ import type { Notification } from '@/api/notification'
export const useNotificationStore = defineStore('notification', () => { export const useNotificationStore = defineStore('notification', () => {
const unreadCount = ref(0) const unreadCount = ref(0)
const urgentNotifications = ref<Notification[]>([]) const urgentNotifications = ref<Notification[]>([])
/** 上次获取未读计数的时间戳 */
const lastCountFetchTime = ref(0)
const COUNT_CACHE_TTL = 30 * 1000 // 30 秒
async function fetchUnreadCount() { async function fetchUnreadCount(force = false) {
if (!force && lastCountFetchTime.value && Date.now() - lastCountFetchTime.value < COUNT_CACHE_TTL) {
return
}
try { try {
const data = await api.getUnreadCount() const data = await api.getUnreadCount()
unreadCount.value = data.count unreadCount.value = data.count
lastCountFetchTime.value = Date.now()
} catch { } catch {
// 静默失败,不影响页面 // 静默失败,不影响页面
} }
@@ -46,5 +53,5 @@ export const useNotificationStore = defineStore('notification', () => {
} }
} }
return { unreadCount, urgentNotifications, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead } return { unreadCount, urgentNotifications, lastCountFetchTime, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead }
}) })

View File

@@ -36,8 +36,8 @@ export const useStatsStore = defineStore('stats', () => {
} }
} }
async function fetchCategoryStats(month?: string, type: string = 'expense') { async function fetchCategoryStats(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
const data = await api.getCategoryStats(month || getCurrentMonth(), type, getGroupId()) const data = await api.getCategoryStats(month || getCurrentMonth(), type, getGroupId(), period)
categoryStats.value = Array.isArray(data) ? data.map(item => ({ categoryStats.value = Array.isArray(data) ? data.map(item => ({
...item, ...item,
amount: Number(item.amount) || 0, amount: Number(item.amount) || 0,
@@ -45,13 +45,44 @@ export const useStatsStore = defineStore('stats', () => {
})) : [] })) : []
} }
async function fetchTrend(month?: string, type: string = 'expense') { async function fetchTrend(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
const data = await api.getTrend(month || getCurrentMonth(), type, getGroupId()) const data = await api.getTrend(month || getCurrentMonth(), type, getGroupId(), period)
trendData.value = Array.isArray(data) ? data.map(item => ({ trendData.value = Array.isArray(data) ? data.map(item => ({
...item, ...item,
amount: Number(item.amount) || 0 amount: Number(item.amount) || 0
})) : [] })) : []
} }
return { overview, categoryStats, trendData, fetchOverview, fetchCategoryStats, fetchTrend } /** 聚合接口:一次请求返回 overview + category + trend减少 HTTP 往返 */
async function fetchDashboard(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
const data = await api.getDashboard({
month: month || getCurrentMonth(),
type,
period,
group_id: getGroupId()
})
if (data) {
const ov = data.overview || EMPTY_OVERVIEW
overview.value = {
expense: Number(ov.expense) || 0,
income: Number(ov.income) || 0,
expenseCount: Number(ov.expenseCount) || 0,
incomeCount: Number(ov.incomeCount) || 0,
count: Number(ov.count) || 0,
daily: Number(ov.daily) || 0,
dailyIncome: Number(ov.dailyIncome) || 0
}
categoryStats.value = Array.isArray(data.category) ? data.category.map(item => ({
...item,
amount: Number(item.amount) || 0,
count: Number(item.count) || 0
})) : []
trendData.value = Array.isArray(data.trend) ? data.trend.map(item => ({
...item,
amount: Number(item.amount) || 0
})) : []
}
}
return { overview, categoryStats, trendData, fetchOverview, fetchCategoryStats, fetchTrend, fetchDashboard }
}) })

84
client/src/stores/tag.ts Normal file
View File

@@ -0,0 +1,84 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '@/api/tag'
export const useTagStore = defineStore('tag', () => {
const tags = ref<api.Tag[]>([])
const loading = ref(false)
/** 上次成功获取的时间戳ms用于 5 分钟缓存判断 */
const lastFetchTime = ref(0)
const CACHE_TTL = 5 * 60 * 1000 // 5 分钟
/** 获取标签列表 */
async function fetchTags(force = false) {
if (!force && lastFetchTime.value && Date.now() - lastFetchTime.value < CACHE_TTL) {
return // 缓存有效,跳过
}
loading.value = true
try {
const data = await api.getTags()
tags.value = Array.isArray(data) ? data : data.list || []
lastFetchTime.value = Date.now()
} catch {
tags.value = []
} finally {
loading.value = false
}
}
/** 乐观创建标签 */
async function addTag(data: { name: string; color: string }) {
const tempId = -Date.now()
const optimistic: api.Tag = { id: tempId, name: data.name, color: data.color, created_at: new Date().toISOString() }
tags.value.push(optimistic)
lastFetchTime.value = 0 // 使缓存失效
try {
const result = await api.createTag(data)
// 用真实 ID 替换临时 ID
const idx = tags.value.findIndex(t => t.id === tempId)
if (idx !== -1) tags.value[idx].id = result.id
return result.id
} catch (err) {
// 回滚
tags.value = tags.value.filter(t => t.id !== tempId)
uni.showToast({ title: '添加失败', icon: 'none' })
throw err
}
}
/** 乐观更新标签 */
async function updateTag(id: number, data: { name?: string; color?: string }) {
const idx = tags.value.findIndex(t => t.id === id)
if (idx === -1) return
const backup = { ...tags.value[idx] }
Object.assign(tags.value[idx], data)
lastFetchTime.value = 0
try {
await api.updateTag(id, data)
} catch (err) {
// 回滚
tags.value[idx] = backup
uni.showToast({ title: '修改失败', icon: 'none' })
throw err
}
}
/** 删除标签 */
async function deleteTag(id: number) {
await api.deleteTag(id)
tags.value = tags.value.filter(t => t.id !== id)
lastFetchTime.value = 0
}
/** 根据 ID 获取标签 */
function getById(id: number) {
return tags.value.find(t => t.id === id)
}
/** 获取选中的标签 ID 对应的标签列表 */
function getByIds(ids: number[]) {
return tags.value.filter(t => ids.includes(t.id))
}
return { tags, loading, lastFetchTime, fetchTags, addTag, updateTag, deleteTag, getById, getByIds }
})

View File

@@ -8,6 +8,9 @@ export const useTransactionStore = defineStore('transaction', () => {
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
// 待确认删除:{ id: 被删除的交易ID, item: 被删除的交易对象, index: 在列表中的位置, timer: 定时器句柄 }
const pendingDelete = ref<{ id: number; item: api.Transaction; index: number; timer: number } | null>(null)
async function fetchTransactions(params?: api.TransactionParams) { async function fetchTransactions(params?: api.TransactionParams) {
loading.value = true loading.value = true
try { try {
@@ -65,5 +68,57 @@ export const useTransactionStore = defineStore('transaction', () => {
} }
} }
return { transactions, total, loading, fetchTransactions, addTransaction, updateTransaction, deleteTransaction, fetchTransactionById } /** 开始删除乐观先从列表移除3秒内可撤销 */
function startDelete(id: number, sourceItem?: api.Transaction, sourceIndex?: number) {
// 如果有上一条待确认删除,立即确认(不能同时有两条)
if (pendingDelete.value) {
confirmDelete()
}
const idx = transactions.value.findIndex(t => t.id === id)
const item = idx !== -1 ? transactions.value[idx] : sourceItem
if (!item) return false
if (idx !== -1) {
transactions.value.splice(idx, 1)
}
const timer = setTimeout(() => {
confirmDelete()
}, 3000) as unknown as number
pendingDelete.value = { id, item, index: idx !== -1 ? idx : sourceIndex ?? 0, timer }
return true
}
/** 撤销删除(恢复到原位置) */
function cancelDelete() {
if (!pendingDelete.value) return
const { item, index, timer } = pendingDelete.value
clearTimeout(timer)
// 插回原位置
transactions.value.splice(index, 0, item)
pendingDelete.value = null
}
/** 确认删除(实际调接口),返回是否删除成功 */
async function confirmDelete(): Promise<boolean> {
if (!pendingDelete.value) return true
const { id, index, timer } = pendingDelete.value
const item = pendingDelete.value.item
clearTimeout(timer)
pendingDelete.value = null
try {
await api.deleteTransaction(id)
return true
} catch {
// 接口失败,恢复
transactions.value.splice(index, 0, item)
uni.showToast({ title: '删除失败', icon: 'none' })
return false
}
}
return { transactions, total, loading, pendingDelete, fetchTransactions, addTransaction, updateTransaction, deleteTransaction, fetchTransactionById, startDelete, cancelDelete, confirmDelete }
}) })

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import * as api from '@/api/user' import * as api from '@/api/user'
import { API_BASE } from '@/config' import { getAvatarUrl } from '@/utils/avatar'
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const userInfo = ref<api.UserInfo | null>(null) const userInfo = ref<api.UserInfo | null>(null)
@@ -16,11 +16,7 @@ export const useUserStore = defineStore('user', () => {
const avatarUrl = computed(() => { const avatarUrl = computed(() => {
const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || '' const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || ''
if (!filename) return '' return getAvatarUrl(filename)
// 已是完整 URLhttp/blob直接返回
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
// 拼接API_BASE + /user/avatar/ + 文件名
return `${API_BASE}/user/avatar/${filename}`
}) })
async function fetchUserInfo() { async function fetchUserInfo() {

View File

@@ -247,3 +247,65 @@
padding-bottom: constant(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
// ===== 入场动画 =====
// 卡片淡入上浮
@mixin fade-in-up($delay: 0s) {
animation: fadeInUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) $delay both;
}
// 列表项交错淡入
@mixin stagger-item($index: 0, $base-delay: 0.05s) {
opacity: 0;
animation: fadeInUp 0.35s cubic-bezier(0.23, 1, 0.32, 1) ($index * $base-delay) both;
}
// 数字跳动
@mixin count-up {
animation: countPulse 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
// 弹性缩放
@mixin bounce-in($delay: 0s) {
animation: bounceIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) $delay both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(24rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bounceIn {
from {
opacity: 0;
transform: scale(0.85);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes countPulse {
0% { transform: scale(1); }
40% { transform: scale(1.08); }
100% { transform: scale(1); }
}
// 尊重减弱动画偏好
@media (prefers-reduced-motion: reduce) {
.fade-in-up,
.stagger-item,
.bounce-in {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
}

View File

@@ -6,15 +6,19 @@
*/ */
let _resolve: () => void let _resolve: () => void
const _ready = new Promise<void>(resolve => { _resolve = resolve }) let _reject: (err: Error) => void
const _ready = new Promise<void>((resolve, reject) => {
_resolve = resolve
_reject = reject
})
/** 标记 App 启动完成(登录成功) */ /** 标记 App 启动完成(登录成功) */
export function markReady() { _resolve() } export function markReady() { _resolve() }
/** 等待 App 就绪,最多等 8 秒超时(小程序登录可能较慢) */ /** 等待 App 就绪,最多等 8 秒超时 */
export function waitForReady(): Promise<void> { export function waitForReady(): Promise<void> {
return Promise.race([ return Promise.race([
_ready, _ready,
new Promise<void>(resolve => setTimeout(resolve, 8000)) new Promise<void>((_, reject) => setTimeout(() => reject(new Error('App ready timeout')), 8000))
]) ])
} }

View File

@@ -0,0 +1,8 @@
import { API_BASE } from '@/config'
/** 将头像文件名转为完整 URL */
export function getAvatarUrl(filename: string): string {
if (!filename) return ''
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
return `${API_BASE}/user/avatar/${filename}`
}

View File

@@ -36,7 +36,11 @@ export function formatDate(date: string | Date): string {
if (diff === 1) return '昨天' if (diff === 1) return '昨天'
if (diff === 2) return '前天' if (diff === 2) return '前天'
return `${d.getMonth() + 1}${d.getDate()}` // 跨年显示年份
const isCurrentYear = d.getFullYear() === now.getFullYear()
return isCurrentYear
? `${d.getMonth() + 1}${d.getDate()}`
: `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`
} }
export function formatMonth(date: string): string { export function formatMonth(date: string): string {
@@ -44,7 +48,7 @@ export function formatMonth(date: string): string {
return `${year}${parseInt(month)}` return `${year}${parseInt(month)}`
} }
/** 获取当前月份UTC+8 时区,与服务端保持一致) */ /** 获取当前月份UTC+8 时区,与服务端 date.ts 逻辑一致) */
export function getCurrentMonth(): string { export function getCurrentMonth(): string {
const d = new Date() const d = new Date()
const offset = 8 * 60 * 60 * 1000 const offset = 8 * 60 * 60 * 1000

View File

@@ -8,20 +8,85 @@ interface RequestOptions {
data?: any data?: any
} }
let isRedirectingToLogin = false /** 是否正在刷新 token并发控制只允许一次刷新 */
/** 重登录期间挂起的请求,登录成功后统一重试 */ let isRefreshing = false
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = [] /** 刷新期间挂起的请求,刷新成功后统一重试 */
let pendingRequests: Array<{
resolve: (v: any) => void
reject: (e: any) => void
options: RequestOptions
}> = []
/** 保存登录结果到本地存储 */ /** 保存登录结果到本地存储(兼容新旧 token 格式) */
export function saveLoginResult(data: LoginResult) { export function saveLoginResult(data: any) {
uni.setStorageSync('xc:token', data.token) // 向后兼容:旧版返回 token 字段映射到 accessToken
const accessToken = data.accessToken || data.token || ''
const refreshToken = data.refreshToken || ''
const expiresIn = data.expiresIn || 7200
uni.setStorageSync('xc:token', accessToken)
uni.setStorageSync('xc:refreshToken', refreshToken)
uni.setStorageSync('xc:tokenExpiresIn', expiresIn)
uni.setStorageSync('xc:userId', data.userId) uni.setStorageSync('xc:userId', data.userId)
uni.setStorageSync('xc:nickname', data.nickname) uni.setStorageSync('xc:nickname', data.nickname)
uni.setStorageSync('xc:avatar_url', data.avatar_url) uni.setStorageSync('xc:avatar_url', data.avatar_url)
if (data.role) uni.setStorageSync('xc:role', data.role) if (data.role) uni.setStorageSync('xc:role', data.role)
} }
/** 执行重登录 */ /** 清除本地登录信息 */
function clearLoginInfo() {
uni.removeStorageSync('xc:token')
uni.removeStorageSync('xc:refreshToken')
uni.removeStorageSync('xc:tokenExpiresIn')
uni.removeStorageSync('xc:userId')
uni.removeStorageSync('xc:nickname')
uni.removeStorageSync('xc:avatar_url')
uni.removeStorageSync('xc:role')
uni.removeStorageSync('xc:currentGroupId')
}
/** 跳转到登录页 */
function redirectToLogin() {
clearLoginInfo()
// H5 环境刷新页面重新走登录流程
if (typeof window !== 'undefined') {
window.location.reload()
}
}
/** 刷新 token用 refreshToken 换取新的双 token */
async function refreshAccessToken(): Promise<string | null> {
const storedRefreshToken = uni.getStorageSync('xc:refreshToken')
if (!storedRefreshToken) return null
try {
// 直接用 uni.request 发起,避免走 request 函数导致循环
const res: any = await new Promise((resolve, reject) => {
uni.request({
url: API_BASE + '/auth/refresh',
method: 'POST',
data: { refreshToken: storedRefreshToken },
timeout: 10000,
header: { 'Content-Type': 'application/json' },
success: resolve,
fail: reject,
})
})
if (res.statusCode === 200 && res.data?.code === 0) {
const { accessToken, refreshToken: newRefreshToken, expiresIn } = res.data.data
uni.setStorageSync('xc:token', accessToken)
uni.setStorageSync('xc:refreshToken', newRefreshToken)
uni.setStorageSync('xc:tokenExpiresIn', expiresIn || 7200)
return accessToken
}
return null
} catch {
return null
}
}
/** 执行重登录(没有 refreshToken 或刷新失败时的兜底) */
async function reLogin(): Promise<void> { async function reLogin(): Promise<void> {
// H5 环境demo 登录 // H5 环境demo 登录
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -56,34 +121,47 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
const { statusCode, data } = res const { statusCode, data } = res
if (statusCode === 401) { if (statusCode === 401) {
uni.removeStorageSync('xc:token') const errorCode = data?.code
uni.removeStorageSync('xc:userId')
uni.removeStorageSync('xc:nickname')
uni.removeStorageSync('xc:avatar_url')
uni.removeStorageSync('xc:role')
uni.removeStorageSync('xc:currentGroupId')
// 加入重试队列 // code 40101 = token 过期 → 尝试用 refreshToken 刷新
pendingRetries.push({ resolve, reject, options }) if (errorCode === 40101) {
// 加入等待队列
pendingRequests.push({ resolve, reject, options })
if (!isRedirectingToLogin) { if (!isRefreshing) {
isRedirectingToLogin = true isRefreshing = true
reLogin() refreshAccessToken()
.then(() => { .then((newToken) => {
// 处理快照时已入队的 + reLogin 期间新入队的请求 if (newToken) {
const retries = [...pendingRetries] // 刷新成功:重试所有等待中的请求
pendingRetries = [] const retries = [...pendingRequests]
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject)) pendingRequests = []
}) retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
.catch(() => { } else {
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' }) // 刷新失败:尝试完整重登录
const retries = [...pendingRetries] return reLogin().then(() => {
pendingRetries = [] const retries = [...pendingRequests]
retries.forEach(r => r.reject({ code: 40100, message: '未登录' })) pendingRequests = []
}) retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
.finally(() => { isRedirectingToLogin = false }) })
}
})
.catch(() => {
// 重登录也失败
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
const retries = [...pendingRequests]
pendingRequests = []
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
})
.finally(() => { isRefreshing = false })
}
return
} }
// code 40100 = token 无效/未登录 → 直接清 token 跳登录
redirectToLogin()
reject({ code: 40100, message: data?.message || '未登录' })
return return
} }

View File

@@ -14,8 +14,8 @@ interface TrackEvent {
/** 上报队列(批量上报,减少请求) */ /** 上报队列(批量上报,减少请求) */
let eventQueue: TrackEvent[] = [] let eventQueue: TrackEvent[] = []
let flushTimer: number | null = null let flushTimer: number | null = null
const FLUSH_INTERVAL = 5000 // 5秒上报一次 const FLUSH_INTERVAL = 500 // 500ms 攒批
const MAX_QUEUE_SIZE = 20 const MAX_QUEUE_SIZE = 10 // 10 条立即发送
/** 获取当前页面路径 */ /** 获取当前页面路径 */
function getCurrentPage(): string { function getCurrentPage(): string {

14
client/vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
test: {
environment: 'jsdom',
globals: true,
},
})

File diff suppressed because it is too large Load Diff

577
docs/arch-alignment.md Normal file
View File

@@ -0,0 +1,577 @@
# 小菜记账 增量架构设计文档
> 版本v1.0 | 架构师高见远Gao | 日期2025-07
---
## Part A: 系统设计
### 1. 实现方案 + 框架选型
#### 1.1 分类拖拽排序P0-1
**技术挑战**:微信小程序不支持 HTML5 Drag and Drop APIUni-app 的 `movable-area`/`movable-view` 适合单元素拖拽而非列表重排。
**方案**:基于 Touch 事件的自定义拖拽排序组件 `DragSortList`
- 监听 `@touchstart` / `@touchmove` / `@touchend` 三组事件
- 拖拽时通过 `transform: translateY()` 实现元素位移动画
- 拖拽过程中实时计算目标索引,交换数组元素位置
- 松手后调用已就绪的 `sortCategories(ids)` API 批量更新排序
- 支出/收入各自独立排序(复用现有 `tabType` 切换 + `currentCategories` computed
- **不引入第三方拖拽库**——Uni-app 小程序环境下无成熟可用方案,自研可控性更高
#### 1.2 服务端导出流式响应P2-3
**方案**Node.js 原生 Stream + `pipeline()` 管道。
- **< 1 万条**:使用 `Readable.from()` 创建内存流,逐行 push 数据,通过 `pipeline()` 管道传输到 Response
- **≥ 1 万条**:先写入临时文件(`os.tmpdir()`),再 `fs.createReadStream()` 流式返回,完成后删除临时文件
- CSV 格式:手动拼装(无需额外依赖),添加 BOM 头确保中文兼容
- JSON 格式:流式写入 `[` + 逐条 `,` 分隔 + `]`,避免全量 JSON.stringify
- Response Headers`Content-Type: text/csv; charset=utf-8` / `application/json; charset=utf-8``Content-Disposition: attachment; filename=...`
- 超时保护:流式响应不设 `express.json()` body 限制,但设 60s 超时
#### 1.3 标签系统数据库设计P3-2~4
**设计原则**:标签不区分收支类型,跨分类使用,轻量关联表。
- `tags` 表:用户维度,每用户最多 20 个标签8 色预设
- `transaction_tags` 表:多对多关联,每笔交易最多 5 个标签
- 标签与交易在同一事务中写入(`POST/PUT /transactions` 扩展 `tagIds` 字段)
- 查询交易列表时 LEFT JOIN 聚合标签信息(避免 N+1 查询)
- 统计按标签聚合使用 `GROUP BY tag_id`
#### 1.4 数据导入P2-2
**方案**JSON 格式导入,服务端校验 + 批量写入 + 去重。
- 接收 JSON 数组校验字段格式必填date, type, amount, category_name
-`date + type + amount + note` 组合去重(查现有记录比对)
- 使用 `INSERT ... VALUES (...), (...), ...` 批量写入(每批 100 条)
- 返回结果:`{ total, imported, skipped, errors[] }`
#### 1.5 统计年度/周视图P2-1
**方案**:后端 `period` 参数扩展 + 前端维度切换器。
- 后端:`GET /stats/category``GET /stats/trend` 新增 `period=week|month|year` 参数
- `week`:基于当前日期计算本周一至本周日,趋势 X 轴为周一~周日
- `month`:保持现有行为(默认)
- `year`:基于当前年份 1-12 月,趋势 X 轴为 1月~12月
- 年视图日期范围计算:`startDate = ${year}-01-01`, `endDate = ${year}-12-31`
- 周视图日期范围计算:获取本周一和本周日
- 趋势数据 GROUP BY 逻辑:年视图按月 GROUP周视图按日 GROUP
---
### 2. 文件列表及相对路径
#### 2.1 新建文件
| 文件路径 | 说明 |
|---------|------|
| `client/src/api/backup.ts` | 备份管理 API下载备份 |
| `client/src/api/tag.ts` | 标签 CRUD + 统计 API |
| `client/src/api/export.ts` | 服务端导出 API |
| `client/src/api/health.ts` | 健康检查 API |
| `client/src/stores/tag.ts` | 标签 Pinia Store |
| `client/src/components/DragSortList/DragSortList.vue` | 拖拽排序列表组件 |
| `client/src/pages/backup-manage/index.vue` | 备份管理页面(仅管理员) |
| `client/src/pages/data-import/index.vue` | 数据导入页面 |
| `client/src/pages/tag-manage/index.vue` | 标签管理页面 |
| `server/src/routes/tag.ts` | 标签 CRUD + 按标签统计路由 |
| `server/src/routes/export.ts` | 服务端导出路由 |
#### 2.2 修改文件
| 文件路径 | 主要变更 |
|---------|---------|
| `server/src/db/schema.sql` | 新增 tags、transaction_tags 表定义 |
| `server/src/db/init.ts` | 新增 tags、transaction_tags 表迁移 |
| `server/src/routes/feedback.ts` | 新增 `GET /mine` 端点(用户查看自己的反馈) |
| `server/src/routes/backup.ts` | 新增 `GET /:id/download` 端点(临时签名 URL |
| `server/src/routes/stats.ts` | category/trend 端点增加 `period` 参数支持 |
| `server/src/routes/transaction.ts` | POST/PUT 支持 `tagIds`GET 支持 `tagId` 筛选GET/:id 返回 tags |
| `server/src/index.ts` | health 端点移到 authMiddleware 之前;注册 tag、export 路由 |
| `client/src/api/feedback.ts` | 新增 `getMyFeedbacks()` API 函数 |
| `client/src/api/stats.ts` | 修改 `getCategoryStats``getTrend` 支持 `period` 参数 |
| `client/src/api/transaction.ts` | 新增 `importTransactions()`;类型扩展 `tagIds``tagId` |
| `client/src/api/admin.ts` | 新增 `getHealth()` API 函数 |
| `client/src/stores/category.ts` | 无需修改sortCategories 已就绪) |
| `client/src/stores/stats.ts` | fetchCategoryStats/fetchTrend 支持 period 参数 |
| `client/src/pages/category-manage/index.vue` | 集成 DragSortList 拖拽排序 |
| `client/src/pages/feedback/index.vue` | 新增"我的反馈"Tab |
| `client/src/pages/stats/index.vue` | 添加维度切换器(周/月/年) |
| `client/src/pages/profile/index.vue` | 导出面板增加"服务端导出"选项 |
| `client/src/pages/add/index.vue` | 添加标签选择入口 |
| `client/src/pages/bills/index.vue` | 添加标签筛选功能 |
| `client/src/pages/admin/index.vue` | 添加服务状态卡片 |
| `client/src/pages.json` | 注册新页面路由 |
---
### 3. 数据结构和接口(类图)
```mermaid
classDiagram
class Tag {
+int id
+int user_id
+string name
+string color
+string created_at
}
class TransactionTag {
+int transaction_id
+int tag_id
}
class Transaction {
+int id
+int user_id
+int amount
+string type
+int category_id
+string note
+string date
+int group_id
+int recurring_id
+Tag[] tags
}
class Feedback {
+int id
+int user_id
+string type
+string content
+string contact
+string status
+string admin_reply
+string created_at
}
class Category {
+int id
+int user_id
+string name
+string icon
+string color
+string type
+int sort_order
+int is_custom
}
Transaction "1" --o "*" TransactionTag : has
Tag "1" --o "*" TransactionTag : referenced_by
Transaction ..> Category : belongs_to
note for Tag "每用户最多 20 个标签\n8 色预设颜色选择器\n标签不区分收支类型"
note for TransactionTag "复合主键 (transaction_id, tag_id)\n每笔交易最多 5 个标签"
```
#### 3.1 新增数据库表
**tags 表**
```sql
CREATE TABLE IF NOT EXISTS tags (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(20) NOT NULL COMMENT '标签名称',
color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_name (user_id, name),
INDEX idx_user (user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
**transaction_tags 表**
```sql
CREATE TABLE IF NOT EXISTS transaction_tags (
transaction_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (transaction_id, tag_id),
INDEX idx_tag (tag_id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
#### 3.2 新增/修改 API 端点
##### 标签 CRUD
| 方法 | 路径 | 请求 | 响应 | 说明 |
|------|------|------|------|------|
| GET | `/api/tags` | — | `{ list: Tag[] }` | 获取当前用户所有标签 |
| POST | `/api/tags` | `{ name, color }` | `{ id }` | 创建标签(限 20 个/用户) |
| PUT | `/api/tags/:id` | `{ name?, color? }` | — | 更新标签 |
| DELETE | `/api/tags/:id` | — | — | 删除标签(级联删除关联) |
| GET | `/api/stats/by-tag` | `?month&period&type` | `TagStat[]` | 按标签统计 |
**TagStat 类型**
```ts
{ id: number; name: string; color: string; amount: number; count: number }
```
##### 用户反馈列表
| 方法 | 路径 | 请求 | 响应 | 说明 |
|------|------|------|------|------|
| GET | `/api/feedback/mine` | `?page&pageSize` | `{ list, total, page, pageSize }` | 当前用户的反馈列表 |
##### 备份下载
| 方法 | 路径 | 请求 | 响应 | 说明 |
|------|------|------|------|------|
| GET | `/api/backup/:id/download` | — | 文件流 | 下载备份文件(临时签名 URL1h 有效) |
**实现方式**:在 `backup` 表中无需存储,使用 HMAC-SHA256 生成带过期时间的签名 token下载时校验签名。签名格式`backupId:timestamp:hmac-sha256(backupId:timestamp)`。将签名作为 `?token=xxx` 传给前端,下载端点校验 token 有效性(无需认证中间件)。
##### 统计年度/周视图
| 方法 | 路径 | 请求参数 | 说明 |
|------|------|---------|------|
| GET | `/api/stats/category` | 新增 `period=week\|month\|year` | 周/月/年分类统计 |
| GET | `/api/stats/trend` | 新增 `period=week\|month\|year` | 周/月/年趋势数据 |
**period 参数逻辑**
- `week`(新增):`startDate` = 本周一,`endDate` = 本周日,趋势按日 GROUP
- `month`(默认):保持现有行为,趋势按日 GROUP
- `year`(新增):`startDate` = 当年1月1日`endDate` = 当年12月31日趋势按月 GROUP`DATE_FORMAT(date, '%Y-%m')`
**年视图趋势响应**(新增 `label` 字段):
```ts
{ label: string; date: string; amount: number }[]
// 年视图 label = "1月"~"12月"
// 周视图 label = "周一"~"周日"
// 月视图 label = "1日"~"31日"(保持兼容)
```
##### 数据导入
| 方法 | 路径 | 请求 | 响应 | 说明 |
|------|------|------|------|------|
| POST | `/api/transactions/import` | `{ items: ImportItem[] }` | `{ total, imported, skipped, errors[] }` | 批量导入 |
**ImportItem 类型**
```ts
{
date: string // 必填YYYY-MM-DD
type: string // 必填expense | income
amount: number // 必填,单位:分
category_name: string // 可选,匹配不到则归入"未分类"
note?: string
tag_names?: string[] // 可选,按名称匹配标签
}
```
##### 服务端导出
| 方法 | 路径 | 请求参数 | 响应 | 说明 |
|------|------|---------|------|------|
| GET | `/api/export` | `startDate, endDate, type, format=csv\|json` | 文件流 | 流式导出 |
##### 健康检查(改造)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/health` | 移到 `authMiddleware` 之前,无需认证 |
##### 交易记录(改造)
| 方法 | 路径 | 变更 |
|------|------|------|
| POST | `/api/transactions` | 请求新增 `tagIds: number[]`(可选,最多 5 个) |
| PUT | `/api/transactions/:id` | 请求新增 `tagIds: number[]`(可选,最多 5 个,全量替换) |
| GET | `/api/transactions` | 新增 `tagId` 查询参数筛选 |
| GET | `/api/transactions/:id` | 响应新增 `tags: { id, name, color }[]` |
---
### 4. 程序调用流程(时序图)
#### 4.1 分类拖拽排序
```mermaid
sequenceDiagram
participant U as 用户
participant P as category-manage
participant D as DragSortList
participant S as categoryStore
participant A as sortCategories API
participant B as PUT /categories/sort
U->>P: 长按分类项进入排序模式
P->>P: showSortMode = true
P->>D: 渲染 DragSortList (items=currentCategories)
U->>D: touchstart (记录起始位置)
U->>D: touchmove (计算偏移, 交换元素位置)
D->>D: 实时更新 items 数组顺序
U->>D: touchend (拖拽结束)
D->>P: @change事件 (新顺序ids)
P->>S: sortCategories(newIds)
S->>A: sortCategories(ids)
A->>B: PUT /categories/sort { ids }
B-->>A: { code: 0 }
A-->>S: 成功
S->>S: fetchCategories() 刷新
```
#### 4.2 数据导入
```mermaid
sequenceDiagram
participant U as 用户
participant P as data-import
participant A as transaction API
participant S as POST /transactions/import
participant DB as MySQL
U->>P: 选择 JSON 文件
P->>P: uni.chooseFile / 读取文件内容
P->>P: 解析 JSON显示预览(条数、日期范围)
U->>P: 确认导入
P->>A: importTransactions(items)
A->>S: POST /transactions/import { items }
S->>S: 校验每条记录格式
S->>DB: 查询已有记录 (去重比对)
S->>DB: 批量 INSERT (每批100条)
S->>DB: 写入 transaction_tags (如有 tag_names)
S-->>A: { total, imported, skipped, errors }
A-->>P: 导入结果
P->>U: 显示导入结果(成功X条, 跳过Y条)
```
#### 4.3 标签关联交易
```mermaid
sequenceDiagram
participant U as 用户
participant P as add/index
participant TS as tagStore
participant TA as transaction API
participant S as POST /transactions
participant DB as MySQL
U->>P: 点击"添加标签"
P->>P: 显示标签选择面板(已有标签 + 新建入口)
U->>P: 选择标签(最多5个)
P->>P: selectedTagIds 更新
U->>P: 保存交易
P->>TA: createTransaction({...data, tagIds})
TA->>S: POST /transactions { amount, type, ..., tagIds }
S->>S: 校验 tagIds (≤5, 属于当前用户)
S->>DB: INSERT INTO transactions
S->>DB: INSERT INTO transaction_tags (批量)
S-->>TA: { id }
TA-->>P: 成功
Note over P,U: 编辑交易时同理PUT 全量替换 tagIds
```
#### 4.4 服务端导出
```mermaid
sequenceDiagram
participant U as 用户
participant P as profile/index
participant A as export API
participant S as GET /api/export
participant DB as MySQL
U->>P: 点击"服务端导出"
P->>A: serverExport(params)
A->>S: GET /api/export?startDate=&endDate=&type=&format=
S->>DB: SELECT COUNT(*) (判断数量)
alt 记录 < 10000
S->>DB: 流式查询 (cursor)
S->>S: Readable.from() 逐行生成 CSV/JSON
S-->>P: 流式响应 (Transfer-Encoding: chunked)
else 记录 ≥ 10000
S->>DB: 流式查询写入临时文件
S->>S: fs.createReadStream()
S-->>P: 流式响应
S->>S: 删除临时文件
end
P->>U: 保存文件到本地
```
---
### 5. 待明确事项
| # | 事项 | 当前假设 |
|---|------|---------|
| 1 | 备份下载的签名 URL 是通过新端点直接下载,还是返回签名 URL 让前端跳转? | 采用直接下载方式:`GET /api/backup/:id/download?token=xxx`,后端校验 token 后流式返回文件 |
| 2 | 年视图/周视图的 `month` 参数如何处理? | 新增 `period` 参数,当 `period=year``month` 参数被忽略,使用当前年份;当 `period=week` 时使用当前周 |
| 3 | 导入 JSON 的 `category_name` 匹配不到时如何处理? | 归入"未分类"category_id = NULL不自动创建分类 |
| 4 | 标签预设 8 色具体色值? | 复用现有 `colorOptions``#FF8C69, #7BC67E, #5B9BD5, #FFD700, #FF69B4, #8B5CF6, #F97316, #06B6D4` |
| 5 | 健康检查移到 authMiddleware 前是否影响安全? | `/api/health` 仅返回 DB 连接状态和服务器时间,不泄露敏感信息,安全无影响 |
| 6 | 数据导入文件大小限制? | 前端限制 5MB后端 `express.json({ limit: '5mb' })` 仅对导入端点放宽(当前全局 10kb |
---
## Part B: 任务分解
### 6. 需要新增的依赖包
#### 后端 (server)
```
无新增依赖
```
> 流式导出使用 Node.js 内置 `stream`、`fs`、`os` 模块CSV 拼装手写无需 `json2csv`,签名 URL 使用已有 `crypto` 模块。
#### 前端 (client)
```
无新增依赖
```
> 拖拽排序使用 Touch 事件自研实现,不引入第三方拖拽库。
---
### 7. 任务列表(按依赖顺序)
#### T01: 后端基础设施 + 数据库迁移
**描述**:新增 tags/transaction_tags 表、修改 health 端点位置、注册新路由模块、调整 express.json 限制。
**涉及文件**
- `server/src/db/schema.sql` — 新增 tags、transaction_tags 表定义
- `server/src/db/init.ts` — 新增迁移逻辑(创建 tags、transaction_tags 表)
- `server/src/index.ts` — health 移到 authMiddleware 前;注册 tag、export 路由;导入端点 body 限制调整
- `server/src/routes/tag.ts` — 新建:标签 CRUD 端点 + 按标签统计端点
- `server/src/routes/export.ts` — 新建:服务端流式导出端点
**依赖任务**:无
**优先级**P0
---
#### T02: 后端 API 改造(现有路由扩展)
**描述**:扩展现有路由以支持新功能——反馈 mine 端点、备份下载、交易 tagIds 支持、统计 period 参数。
**涉及文件**
- `server/src/routes/feedback.ts` — 新增 `GET /mine` 端点
- `server/src/routes/backup.ts` — 新增 `GET /:id/download` 端点(签名 URL 校验)
- `server/src/routes/transaction.ts` — POST/PUT 支持 tagIdsGET 支持 tagId 筛选GET/:id 返回 tags
- `server/src/routes/stats.ts` — category/trend 增加 period 参数week/year 视图)
**依赖任务**T01tags 表需先存在)
**优先级**P0
---
#### T03: 前端 API 层 + Store 层 + 新页面
**描述**:新增所有前端 API 函数和 Store创建新页面标签管理、备份管理、数据导入创建拖拽组件。
**涉及文件**
- `client/src/api/feedback.ts` — 新增 `getMyFeedbacks()`
- `client/src/api/backup.ts` — 新建:备份列表 + 下载 API
- `client/src/api/tag.ts` — 新建:标签 CRUD + 统计 API
- `client/src/api/export.ts` — 新建:服务端导出 API
- `client/src/api/health.ts` — 新建:健康检查 API
- `client/src/api/stats.ts` — 修改getCategoryStats/getTrend 支持 period
- `client/src/api/transaction.ts` — 新增 `importTransactions()`;类型扩展 tagIds/tagId
- `client/src/api/admin.ts` — 新增 `getHealth()`
- `client/src/stores/tag.ts` — 新建:标签 Pinia Store
- `client/src/stores/stats.ts` — 修改fetchCategoryStats/fetchTrend 支持 period
- `client/src/components/DragSortList/DragSortList.vue` — 新建:拖拽排序组件
- `client/src/pages/tag-manage/index.vue` — 新建:标签管理页面
- `client/src/pages/backup-manage/index.vue` — 新建:备份管理页面
- `client/src/pages/data-import/index.vue` — 新建:数据导入页面
- `client/src/pages.json` — 注册新页面路由
**依赖任务**T01需知 API 端点格式)
**优先级**P1
---
#### T04: 前端现有页面改造
**描述**:改造现有页面以集成新功能——分类拖拽、反馈列表、统计维度切换、标签入口、服务端导出、健康检查卡片。
**涉及文件**
- `client/src/pages/category-manage/index.vue` — 集成 DragSortList 拖拽排序
- `client/src/pages/feedback/index.vue` — 新增"我的反馈"Tab
- `client/src/pages/stats/index.vue` — 添加维度切换器(周/月/年)+ 标签统计入口
- `client/src/pages/profile/index.vue` — 导出面板增加"服务端导出"选项
- `client/src/pages/add/index.vue` — 添加标签选择入口
- `client/src/pages/bills/index.vue` — 添加标签筛选功能
- `client/src/pages/admin/index.vue` — 添加服务状态卡片30s 自动刷新)
**依赖任务**T03依赖 API 层和组件)
**优先级**P1
---
#### T05: 集成联调 + 边界处理
**描述**:前后端联调、边界场景处理(导入去重、大量数据导出、拖拽边界)、最终测试修复。
**涉及文件**
- 可能涉及 T01~T04 中所有文件的微调
- 重点:`server/src/routes/export.ts`(大量数据流式导出稳定性)
- 重点:`server/src/routes/transaction.ts`(导入去重逻辑)
- 重点:`client/src/components/DragSortList/DragSortList.vue`(拖拽边界场景)
**依赖任务**T04
**优先级**P2
---
### 8. 共享知识(跨文件约定)
```
- API 响应格式统一:{ code: 0, data: T } 成功;{ code: 5位数字, message: string } 失败
- 错误码约定:
- 40001: 参数无效
- 40002: 类型无效
- 40100: 未登录
- 40101: token过期
- 40300: 无权限
- 40400: 资源不存在
- 42900: 请求频繁
- 50000: 服务器内部错误
- 金额单位统一为"分"INT前端展示时 / 100
- 日期格式统一为 YYYY-MM-DD月份为 YYYY-MM
- 认证使用 HMAC-SHA256 TokenBearer 格式30天有效期
- 公开路径(无需认证):/api/auth/login, /api/auth/demo-login, /api/health, /api/backup/:id/downloadtoken 校验)
- 前端 request() 函数自动处理 401 重登录、错误 toast
- Pinia Store 方法命名fetch* (获取数据)、add/create (新增)、update (更新)、delete (删除)
- 前端 API 函数命名get* (GET), create*/submit* (POST), update*/sort* (PUT), delete* (DELETE)
- SCSS 变量:$primary=#FF8C69, $success=#7BC67E, $danger=#FF6B6B, $text=#2D1B1B, $surface=#FFFFFF, $bg=#FFF8F0
- 组件样式使用 scoped + @import '@/styles/mixins.scss'
- 标签预设 8 色:#FF8C69, #7BC67E, #5B9BD5, #FFD700, #FF69B4, #8B5CF6, #F97316, #06B6D4
- 每笔交易最多 5 个标签,每用户最多 20 个标签
- 导入文件限制 5MB导出流式响应超时 60s
- 分页参数page (从1开始), pageSize (默认20, 最大100)
```
---
### 9. 任务依赖图
```mermaid
graph LR
T01[T01: 后端基础设施+DB迁移] --> T02[T02: 后端API改造]
T01 --> T03[T03: 前端API+Store+新页面]
T02 --> T04[T04: 前端现有页面改造]
T03 --> T04
T04 --> T05[T05: 集成联调+边界处理]
```

919
docs/arch-iteration-v2.md Normal file
View File

@@ -0,0 +1,919 @@
# 小菜记账 — 全量迭代架构设计 v2
> 版本: v2.0 | 日期: 2026-06-10
> 架构师: 高见远Gao
> 基线: PRD v2.035 项改进点6 个维度)
> 技术栈: Uni-app + Vue 3 + Pinia + SCSS (前端) | Node.js + Express + MySQL 8.0 (后端)
---
## 一、实现方案 + 技术选型
### 1.1 安全修复方案
#### S1.env 凭据泄露修复
| 项目 | 方案 |
|------|------|
| 核心思路 | 凭据轮换 + Git 历史重写 + pre-commit 防护 |
| 工具 | `git-filter-repo`Python比 BFG 更安全,支持 blob 回调替换) |
| 防护 | `.githooks/pre-commit` 拦截 `.env` 文件提交 |
| 回滚 | 操作前 `git clone --mirror` 完整备份 |
| 执行顺序 | ① 轮换所有凭据 → ② 重写 Git 历史 → ③ 强制推送 → ④ 验证 .gitignore |
#### S2logs.ts SQL 拼接修复
| 项目 | 方案 |
|------|------|
| 当前问题 | `LIMIT ${pSize} OFFSET ${offset}` 模板字符串直接拼接 |
| 修复方案 | `parseInt` + 范围校验后使用 `pool.query` + `?` 占位符 |
| 关键点 | `pool.query` 支持 `?` 占位符用于整数参数;`pool.execute` 的 prepared statement 对 LIMIT/OFFSET 类型要求严格,故选用 `pool.query` |
#### S3TOKEN_SECRET 弱默认值 + 命名不一致
| 项目 | 方案 |
|------|------|
| 核心思路 | 新增 `server/src/config/token.ts` 集中管理,移除所有 fallback |
| 命名统一 | `backup.ts``JWT_SECRET``TOKEN_SECRET`;所有引用改为 `import { TOKEN_SECRET } from '../config/token'` |
| 启动校验 | `TOKEN_SECRET` 未设置或长度 < 32 时 `process.exit(1)` |
| 密钥生成 | `openssl rand -hex 32` 生成 64 字符强密钥 |
#### S4Export/Backup 短期一次性下载凭证
| 项目 | 方案 |
|------|------|
| 核心思路 | 新增 `download_tokens` 表,生成随机凭证(`crypto.randomBytes(32)` |
| 有效期 | 5 分钟,一次性使用后标记 `used_at` |
| 存储 | MySQL 表(写入频率极低,无需 Redis |
| 清理 | 健康检查时附带清理 `expires_at < NOW()` 的记录 |
| 改动 | `backup.ts``export.ts` 的下载逻辑改为先获取凭证再用凭证下载 |
#### S5Refresh Token 机制
| 项目 | 方案 |
|------|------|
| 架构 | 双 Token 机制Access Token2h+ Refresh Token30d |
| 存储 | `refresh_tokens` 表,仅存储 SHA-256 hash不存明文 |
| 轮换 | 每次刷新后旧 Refresh Token 立即失效(`revoked_at`),返回新 Token |
| 前端适配 | `request.ts` 401 → 尝试 refresh → 成功则重试原请求 → refresh 也失败则清 token 跳登录 |
| 安全 | 修改密码时撤销所有 Refresh Token单用户最多 5 个有效 Refresh Token |
| 优势 | 保持现有 HMAC-SHA256 签名方式不变,仅缩短 Access Token 有效期 |
#### S6Admin 硬删除 → 软删除 + 二次确认
| 项目 | 方案 |
|------|------|
| DB 变更 | `users` 表新增 `deleted_at DATETIME DEFAULT NULL` + 索引 |
| 删除操作 | `UPDATE users SET deleted_at = NOW() WHERE id = ?` |
| 恢复接口 | `POST /api/admin/users/:id/restore``SET deleted_at = NULL` |
| 全局过滤 | 所有涉及 `users` 表的查询增加 `WHERE deleted_at IS NULL` |
| 前端确认 | 管理页点击删除 → 弹窗二次确认 → 调用软删除接口 |
#### S7DB 连接移除默认凭据
| 项目 | 方案 |
|------|------|
| 修复 | 移除所有 `|| 'localhost'``|| 'xiaocai'``|| 'xiaocai123'` fallback |
| 启动校验 | 遍历必需环境变量,缺失任一即 `process.exit(1)` |
### 1.2 通用组件抽取方案
#### ManageList 组件
```
client/src/components/ManageList/ManageList.vue
Props:
- items: T[] // 列表数据
- displayField: string // 显示字段名
- colorField?: string // 颜色字段名(标签用)
- iconField?: string // 图标字段名(分类用)
- showDrag?: boolean // 是否显示拖拽排序
- showEdit?: boolean // 是否显示编辑按钮
- showDelete?: boolean // 是否显示删除按钮
- emptyText?: string // 空状态文案
Emits:
- add()
- edit(item: T)
- delete(item: T)
- sort(ids: number[])
Slots:
- item-icon="{ item }" // 自定义图标区域(分类用 CategoryIcon
- item-badge="{ item }" // 自定义标签区域
```
#### ColorPicker 组件
```
client/src/components/ColorPicker/ColorPicker.vue
Props:
- modelValue: string // 当前选中颜色
- colors?: string[] // 可选颜色列表(默认 8 色)
Emits:
- update:modelValue(color: string)
```
#### EditModal 组件
```
client/src/components/EditModal/EditModal.vue
Props:
- visible: boolean
- title: string
- fields: EditField[] // 动态字段配置
- modelValue: Record<string, any>
Emits:
- update:visible(val: boolean)
- confirm(data: Record<string, any>)
interface EditField {
key: string
label: string
type: 'text' | 'color'
placeholder?: string
maxlength?: number
}
```
### 1.3 乐观更新实现模式
```
模式:先写本地 state再异步调接口失败则回滚
async function addCategory(data) {
// 1. 生成临时 ID负数避免与真实 ID 冲突)
const tempId = -Date.now()
const optimisticItem = { id: tempId, ...data }
// 2. 立即更新本地 state
categories.value.push(optimisticItem)
try {
// 3. 调用 API
const result = await api.createCategory(data)
// 4. 用真实 ID 替换临时 ID
const idx = categories.value.findIndex(c => c.id === tempId)
if (idx !== -1) categories.value[idx].id = result.id
} catch (err) {
// 5. 回滚:移除临时项
categories.value = categories.value.filter(c => c.id !== tempId)
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
适用范围:
✅ 新增分类/标签、编辑分类/标签、拖拽排序
❌ 删除操作仍等接口确认(防误删)
```
### 1.4 Refresh Token 实现方案
```
┌──────────┐ POST /auth/login ┌──────────┐
│ Client │ ──────────────────────────>│ Server │
│ │<──────────────────────────│ │
│ │ { accessToken, refreshToken } │
│ │ │ │
│ │ GET /api/xxx │ │
│ │ Header: Bearer <accessToken> │
│ │ ──────────────────────────>│ │
│ │<──────────────────────────│ │
│ │ 200 OK │ │
│ │ │ │
│ │ GET /api/xxx (token 过期) │ │
│ │ ──────────────────────────>│ │
│ │<──────────────────────────│ │
│ │ 401 { code: 40101 } │ │
│ │ │ │
│ │ POST /auth/refresh │ │
│ │ { refreshToken } │ │
│ │ ──────────────────────────>│ │
│ │<──────────────────────────│ │
│ │ { accessToken, refreshToken } │
│ │ │ │
│ │ 重试原请求 │ │
│ │ ──────────────────────────>│ │
└──────────┘ └──────────┘
```
**后端关键实现**
- `auth.ts` 登录成功时:生成 Access Token2h+ Refresh TokenrandomBytes(32)),存 hash 到 `refresh_tokens`
- 新增 `POST /auth/refresh`:验证 Refresh Token hash → 撤销旧 Token → 生成新双 Token
- `middleware/auth.ts`Access Token 过期返回 `40101`(区别于无效 Token 的 `40100`
**前端关键实现**
- `request.ts`:收到 `40101` → 调用 `/auth/refresh` → 成功则重试 → 失败则清 token 跳登录
- 存储:`xc:accessToken` + `xc:refreshToken` 分开存储
- 并发请求时只触发一次 refresh其他请求排队等待
### 1.5 软删除方案
```
影响范围:
- users 表:新增 deleted_at 字段
- 所有 JOIN users 的查询:增加 deleted_at IS NULL 过滤
- admin.tsDELETE → UPDATE SET deleted_at
- 新增恢复接口
- 登录验证:检查 deleted_at IS NULL
具体改动位置:
1. schema.sql / migrate.sqlALTER TABLE
2. auth.ts 登录WHERE openid = ? AND deleted_at IS NULL
3. admin.ts 用户列表WHERE deleted_at IS NULL或增加"已禁用"筛选)
4. transaction.ts 列表查询JOIN users 时增加 u.deleted_at IS NULL
5. 群组统计/成员列表:增加 deleted_at 过滤
```
### 1.6 iconfont 迁移方案
```
当前状态Icon.vue 使用 PNG 图片映射
迁移方案:
1. 在 iconfont.cn 创建项目,上传 SVG 图标
2. 生成字体文件ttf放入 client/src/static/iconfont/
3. 新建 client/src/styles/iconfont.scss@font-face 声明
4. Icon.vue 改为 <text class="iconfont icon-xxx" /> 方式渲染
5. 保留 name prop 接口不变,内部映射改为 class 名
6. 额外图标(如 lock直接在 iconfont 项目中添加
收益:
- 包体积减小(字体 < 20KB vs 多个 PNG
- 支持动态颜色color prop 直接生效)
- 新增图标只需上传 SVG无需切图
```
### 1.7 离线队列方案
```
架构设计:
1. 离线队列管理器client/src/utils/offline.ts
- pendingOps: 存储于 uni.setStorageSync('xc:pendingOps')
- 数据结构: Array<{ id: string, type: 'create', data: object, createdAt: number, status: 'pending' | 'failed' }>
- MVP 仅支持 create新增记账
2. 网络状态监听
- App.vue onLaunch 中注册 uni.onNetworkStatusChange
- 网络恢复时自动调用 processPendingOps()
3. 操作流程
- 有网络:正常调接口
- 无网络:
├── 写入 pendingOps
├── 本地 state 立即更新(乐观更新 + "待同步"标记)
└── Toast "已保存,将在网络恢复后同步"
4. 同步流程
- 按时间顺序逐条执行
- 全部成功:清除队列 + toast "同步完成"
- 部分失败:标记失败项 + 保留在队列 + toast "N 条同步失败"
5. 网络检测
- uni.getNetworkType() 获取当前网络状态
- 封装 isOnline(): boolean 工具函数
```
### 1.8 财务报告推送方案
```
架构设计:
1. 定时任务server/src/services/scheduler.ts
- 使用 node-cron 实现
- 每周一 09:00生成上周收支摘要 → 推送
- 每月 1 号 09:00生成上月收支报告 → 推送
2. 报告生成server/src/services/report.ts
- 查询指定时间范围的收支数据
- 生成摘要文本(总支出/收入、Top 分类、日均消费等)
3. 微信订阅消息server/src/services/wechat-subscribe.ts
- 调用微信 subscribeMessage.send 接口
- 模板 ID 需在微信公众平台申请
- 用户需先授权订阅(一次性授权,每次推送需用户主动触发授权)
4. 降级策略
- 优先站内通知(现有 notifications 系统)
- 微信订阅消息作为增强项
- 如果模板审核不通过,仅保留站内通知
5. 用户设置
- user_settings 表存储推送开关
- 客户端设置页增加"财务报告推送"开关
```
### 1.9 预算预警方案
```
架构设计:
1. 双重检查策略
- 实时检查:记账后立即检查预算(精准、低开销)
- 每日全量扫描:凌晨定时扫描所有用户预算(覆盖周期记账等非手动场景)
2. 预警等级
- 80%站内通知type: 'personal', is_urgent: false
- 100%:站内通知 + 微信订阅消息
- 120%站内紧急通知is_urgent: true+ 微信订阅消息
3. 去重机制
- 同一用户同一月同一等级只推送一次
- budget_alerts 表记录已推送的预警user_id + month + level → UNIQUE
4. 触发点
- transaction.ts POST 创建记账后 → 调用 checkBudgetAlert(userId, month)
- scheduler.ts 每日扫描 → 批量 checkBudgetAlert
```
### 1.10 测试框架选型
| 层级 | 框架 | 理由 |
|------|------|------|
| 后端集成测试 | Jest + supertest | Express 生态标配supertest 无需启动真实服务器;与 ts-jest 配合良好 |
| 前端 Store 测试 | Vitest + @pinia/testing | Vitest 与 Vite 原生集成Uni-app 基于 Vite@pinia/testing 提供 createTestingPinia() |
| 工具函数测试 | Jest后端+ Vitest前端 | 纯函数测试最简单,适合作为测试练手 |
| API 契约测试 | Jest + ajvJSON Schema | 轻量级方案,不需要引入完整的契约测试框架 |
---
## 二、文件列表及相对路径
### 2.1 新建文件
| 文件路径 | 用途 |
|----------|------|
| `server/src/config/token.ts` | TOKEN_SECRET 集中配置与启动校验 |
| `server/src/services/scheduler.ts` | 定时任务调度node-cron |
| `server/src/services/report.ts` | 财务报告生成逻辑 |
| `server/src/services/budget-alert.ts` | 预算预警检查与推送逻辑 |
| `server/src/services/wechat-subscribe.ts` | 微信订阅消息发送封装 |
| `server/src/utils/upload.ts` | 公共上传处理函数C3 抽取) |
| `server/src/routes/report.ts` | 财务报告 API 路由 |
| `server/tests/setup.ts` | 测试数据库初始化与清理 |
| `server/tests/auth.test.ts` | 认证路由集成测试 |
| `server/tests/transaction.test.ts` | 交易路由集成测试 |
| `server/tests/category.test.ts` | 分类路由集成测试 |
| `server/tests/budget.test.ts` | 预算路由集成测试 |
| `server/tests/stats.test.ts` | 统计路由集成测试 |
| `server/jest.config.js` | Jest 配置 |
| `server/.env.test` | 测试数据库环境变量 |
| `client/src/components/ManageList/ManageList.vue` | 通用管理列表组件 |
| `client/src/components/ColorPicker/ColorPicker.vue` | 颜色选择器组件 |
| `client/src/components/EditModal/EditModal.vue` | 通用编辑弹窗组件 |
| `client/src/components/Snackbar/Snackbar.vue` | 删除撤销 Snackbar 组件 |
| `client/src/utils/offline.ts` | 离线队列管理器 |
| `client/src/utils/avatar.ts` | 头像 URL 拼接工具函数C9 |
| `client/src/static/iconfont/` | iconfont 字体文件目录 |
| `client/src/styles/iconfont.scss` | iconfont 样式声明 |
| `client/tests/stores/transaction.test.ts` | 交易 Store 单元测试 |
| `client/tests/stores/category.test.ts` | 分类 Store 单元测试 |
| `client/tests/utils/format.test.ts` | 格式化工具函数测试 |
| `client/tests/utils/app-ready.test.ts` | app-ready 工具函数测试 |
| `client/vitest.config.ts` | Vitest 配置 |
| `.githooks/pre-commit` | 防止 .env 提交的 Git 钩子 |
### 2.2 修改文件
| 文件路径 | 主要变更 |
|----------|----------|
| **后端 - 安全** | |
| `server/src/db/connection.ts` | 移除默认凭据 fallback增加启动校验 |
| `server/src/middleware/auth.ts` | 引用 config/token.tsAccess Token 有效期缩短为 2h区分 40100/40101 错误码 |
| `server/src/routes/auth.ts` | 引用 config/token.ts实现双 Token 机制;新增 POST /refresh 接口 |
| `server/src/routes/backup.ts` | JWT_SECRET → TOKEN_SECRET下载改用 download_tokens 凭证 |
| `server/src/routes/admin.ts` | 硬删除 → 软删除新增恢复接口N+1 优化 |
| `server/src/routes/logs.ts` | SQL 拼接改为参数化查询 |
| `server/src/routes/export.ts` | 下载改用 download_tokens 凭证 |
| **后端 - 代码质量** | |
| `server/src/routes/recurring.ts` | sync 接口增加事务包裹 |
| `server/src/routes/user.ts` | 上传逻辑抽取使用公共函数 |
| `server/src/routes/notification.ts` | 上传逻辑抽取使用公共函数 |
| **后端 - 性能** | |
| `server/src/routes/stats.ts` | 新增 GET /dashboard 聚合接口 |
| `server/src/routes/track.ts` | 逐条 INSERT → 批量 INSERT |
| `server/src/routes/budget.ts` | 支持 group_id 参数;记账后触发预算预警 |
| `server/src/routes/transaction.ts` | 创建记账后触发预算预警检查 |
| **后端 - DB** | |
| `server/src/db/schema.sql` | 新增 download_tokens、refresh_tokens、budget_alerts、user_settings 表users 增加 deleted_at |
| `server/src/db/migrate.sql` | 对应迁移脚本 |
| **后端 - 入口** | |
| `server/src/index.ts` | 移除 TOKEN_SECRET 警告;注册 report 路由;初始化 scheduler |
| **前端 - 代码质量** | |
| `client/src/stores/category.ts` | 乐观更新try/catch缓存标记 lastFetchTime |
| `client/src/stores/tag.ts` | 乐观更新try/catch缓存标记 lastFetchTime |
| `client/src/stores/transaction.ts` | 删除撤销try/catch |
| `client/src/stores/budget.ts` | try/catch支持 group_id |
| `client/src/stores/notification.ts` | 未读计数缓存 30s |
| `client/src/stores/group.ts` | refreshAll 并行加载 |
| `client/src/stores/stats.ts` | 使用 dashboard 聚合接口 |
| `client/src/stores/user.ts` | Refresh Token 适配 |
| `client/src/utils/format.ts` | 去重getCurrentMonth 已在后端存在) |
| `client/src/utils/request.ts` | Refresh Token 重试逻辑 |
| `client/src/utils/app-ready.ts` | 超时 reject非静默 resolve |
| `client/src/utils/tracker.ts` | 攒批发送500ms 或 10 条) |
| `client/src/config.ts` | 已集中化,搜索残留硬编码 URL |
| `client/src/components/Icon/Icon.vue` | PNG → iconfont 字体图标 |
| `client/src/components/TransactionItem/TransactionItem.vue` | 群组只读标记 |
| `client/src/components/ChartWrapper/` | 延迟渲染IntersectionObserver |
| **前端 - 页面** | |
| `client/src/pages/category-manage/index.vue` | 使用 ManageList/ColorPicker/EditModal 组件 |
| `client/src/pages/tag-manage/index.vue` | 使用 ManageList/ColorPicker/EditModal 组件 |
| `client/src/pages/bills/index.vue` | 删除撤销 Snackbar |
| `client/src/pages/budget/index.vue` | 群组预算视图 |
| `client/src/pages/stats/index.vue` | 使用 dashboard 接口 |
| `client/src/pages/add/index.vue` | 离线记账支持 |
| `client/src/pages.json` | 全局 enablePullDownRefresh |
| `client/src/App.vue` | 注册网络状态监听 |
| **配置** | |
| `server/package.json` | 新增 jest、ts-jest、supertest、node-cron 依赖 |
| `client/package.json` | 新增 vitest、@pinia/testing@vue/test-utils 依赖 |
---
## 三、数据结构和接口
### 3.1 新增/修改的数据库表结构
#### 新增表
```sql
-- 一次性下载凭证表S4
CREATE TABLE IF NOT EXISTS download_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE COMMENT '随机凭证',
user_id INT NOT NULL,
resource_type ENUM('backup', 'export') NOT NULL COMMENT '资源类型',
resource_id VARCHAR(100) DEFAULT '' COMMENT '资源标识(备份名等)',
expires_at DATETIME NOT NULL COMMENT '过期时间',
used_at DATETIME DEFAULT NULL COMMENT '使用时间NULL=未使用)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token),
INDEX idx_expires (expires_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Refresh Token 表S5
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
expires_at DATETIME NOT NULL COMMENT '过期时间',
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间NULL=有效)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_hash (token_hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 预算预警记录表F-2去重用
CREATE TABLE IF NOT EXISTS budget_alerts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
month VARCHAR(7) NOT NULL COMMENT '格式2026-05',
level ENUM('80', '100', '120') NOT NULL COMMENT '预警等级',
group_id INT DEFAULT NULL COMMENT '群组IDNULL=个人)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_month_level (user_id, month, level, group_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 用户设置表F-1 报告推送开关等)
CREATE TABLE IF NOT EXISTS user_settings (
user_id INT PRIMARY KEY,
report_push_enabled TINYINT(1) DEFAULT 1 COMMENT '是否开启财务报告推送',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
#### 修改表
```sql
-- users 表新增软删除字段S6
ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间NULL=正常)';
CREATE INDEX idx_users_deleted ON users(deleted_at);
-- budgets 表:新增 group_id 支持UX-4
ALTER TABLE budgets DROP INDEX uk_user_month;
ALTER TABLE budgets ADD COLUMN group_id INT DEFAULT NULL COMMENT '群组IDNULL=个人预算)';
ALTER TABLE budgets ADD UNIQUE KEY uk_user_group_month (user_id, group_id, month);
ALTER TABLE budgets ADD INDEX idx_group_month (group_id, month);
```
### 3.2 新增/修改的 API 端点定义
#### 新增端点
| 方法 | 路径 | 说明 | 请求体 / 参数 | 响应 |
|------|------|------|---------------|------|
| POST | `/api/auth/refresh` | 刷新 Access Token | `{ refreshToken: string }` | `{ code: 0, data: { accessToken, refreshToken, expiresIn } }` |
| GET | `/api/stats/dashboard` | 聚合统计(一次返回 overview + category + trend | `?month=2026-06&type=expense&period=month&group_id=null` | `{ code: 0, data: { overview, category, trend } }` |
| POST | `/api/admin/users/:id/restore` | 恢复软删除用户 | — | `{ code: 0 }` |
| POST | `/api/download-tokens` | 生成下载凭证 | `{ resourceType: 'backup'|'export', resourceId: string }` | `{ code: 0, data: { token, downloadUrl } }` |
| GET | `/api/download/:token` | 使用凭证下载文件 | `:token` 路径参数 | 文件流或 403 |
| GET | `/api/reports/weekly` | 获取上周报告 | — | `{ code: 0, data: { startDate, endDate, expense, income, topCategories, dailyAvg } }` |
| GET | `/api/reports/monthly` | 获取上月报告 | — | `{ code: 0, data: { month, expense, income, topCategories, dailyAvg, monthOverMonth } }` |
| GET | `/api/user/settings` | 获取用户设置 | — | `{ code: 0, data: { reportPushEnabled, ... } }` |
| PUT | `/api/user/settings` | 更新用户设置 | `{ reportPushEnabled?: boolean }` | `{ code: 0 }` |
#### 修改端点
| 方法 | 路径 | 变更说明 |
|------|------|----------|
| POST | `/api/auth/login` | 返回值新增 `refreshToken``expiresIn` 字段 |
| POST | `/api/auth/demo-login` | 同上 |
| DELETE | `/api/admin/users/:id` | 改为软删除(`SET deleted_at = NOW()` |
| GET | `/api/budget` | 支持 `group_id` 参数;群组视图返回 `groupTotal` + `myAmount` |
| POST | `/api/budget` | 支持 `group_id` 参数 |
| POST | `/api/transactions` | 创建后触发预算预警检查 |
| POST | `/api/track` | 支持批量 `events[]` 数组,后端批量 INSERT |
| GET | `/api/backup/:id/download` | 改用 download_tokens 凭证 |
| GET | `/api/export` | 改用 download_tokens 凭证GET 带 token 参数改为先获取凭证再下载) |
---
## 四、程序调用流图
### 4.1 Refresh Token 刷新流程
```mermaid
sequenceDiagram
participant C as Client (request.ts)
participant S as Server (auth.ts)
participant M as Server (auth middleware)
participant DB as MySQL (refresh_tokens)
Note over C,S: 1. 正常登录
C->>S: POST /auth/login { code }
S->>DB: INSERT refresh_tokens (user_id, token_hash, expires_at)
S-->>C: { accessToken(2h), refreshToken(30d), userId }
Note over C,S: 2. API 调用Access Token 有效)
C->>M: GET /api/xxx Authorization: Bearer <accessToken>
M->>M: 验证 HMAC 签名 + 有效期
M-->>C: 200 OK { data }
Note over C,S: 3. Access Token 过期
C->>M: GET /api/xxx Authorization: Bearer <expiredToken>
M->>M: 签名有效但已过期
M-->>C: 401 { code: 40101, message: 'token已过期' }
Note over C,S: 4. 自动刷新
C->>S: POST /auth/refresh { refreshToken }
S->>DB: SELECT WHERE token_hash=SHA256(refreshToken) AND revoked_at IS NULL AND expires_at>NOW()
DB-->>S: 找到记录
S->>DB: UPDATE refresh_tokens SET revoked_at=NOW() WHERE id=?
S->>DB: INSERT refresh_tokens (新 token_hash)
S-->>C: { accessToken, refreshToken, expiresIn }
C->>C: 存储新 Token重试原请求
```
### 4.2 删除撤销流程
```mermaid
sequenceDiagram
participant U as User
participant P as BillsPage
participant S as TransactionStore
participant SN as Snackbar
participant API as Server API
U->>P: 左滑删除记录
P->>S: pendingDelete(id, item, index)
S->>S: 从 transactions 列表移除
S->>SN: 显示 Snackbar "已删除 1 条记录 [撤销]"
SN-->>P: 3秒倒计时
alt 3秒内点击撤销
U->>SN: 点击 [撤销]
SN->>S: cancelDelete(id)
S->>S: 恢复到列表原位置
SN->>SN: 消失
else 3秒超时
SN->>S: confirmDelete(id)
S->>API: DELETE /api/transactions/:id
alt 删除成功
API-->>S: 200 OK
S->>S: 清除 pendingDelete 记录
else 删除失败
API-->>S: 500 Error
S->>S: 恢复到列表原位置
S->>U: toast "删除失败"
end
SN->>SN: 消失
end
```
### 4.3 预算预警触发流程
```mermaid
sequenceDiagram
participant C as Client
participant T as Transaction Route
participant BA as BudgetAlert Service
participant DB as MySQL
participant NS as Notification System
participant WX as WeChat API
C->>T: POST /api/transactions { amount, type, date }
T->>DB: INSERT INTO transactions
T->>BA: checkBudgetAlert(userId, month)
BA->>DB: SELECT budget FROM budgets WHERE user_id=? AND month=?
BA->>DB: SELECT SUM(amount) FROM transactions WHERE user_id=? AND month=? AND type='expense'
alt 支出/预算 >= 80% 且未推送过
BA->>DB: INSERT budget_alerts (level='80')
BA->>NS: 创建站内通知
NS->>DB: INSERT INTO notifications
else 支出/预算 >= 100%
BA->>DB: INSERT budget_alerts (level='100')
BA->>NS: 创建站内通知
BA->>WX: 发送微信订阅消息
else 支出/预算 >= 120%
BA->>DB: INSERT budget_alerts (level='120')
BA->>NS: 创建紧急站内通知 (is_urgent=true)
BA->>WX: 发送微信订阅消息
end
T-->>C: { code: 0, data: { id } }
```
---
## 五、任务列表
> 按批次分组,每批次包含 1 个任务,共 5 个任务
> 优先级P0阻塞上线> P1必须有> P2锦上添花
### T01: 项目基础设施与安全修复
| 字段 | 内容 |
|------|------|
| **任务 ID** | T01 |
| **优先级** | P0 |
| **描述** | 搭建项目基础设施(依赖安装、配置文件、入口文件、数据库迁移),并完成所有 P0/P1 安全修复S1-S7 |
| **依赖** | 无 |
| **涉及文件** | **新建**`server/src/config/token.ts`, `.githooks/pre-commit`, `server/.env.test`, `server/jest.config.js`, `client/vitest.config.ts` |
| | **修改**`server/package.json`, `client/package.json`, `server/src/db/schema.sql`, `server/src/db/migrate.sql`, `server/src/db/connection.ts`, `server/src/middleware/auth.ts`, `server/src/routes/auth.ts`, `server/src/routes/backup.ts`, `server/src/routes/admin.ts`, `server/src/routes/logs.ts`, `server/src/routes/export.ts`, `server/src/index.ts`, `client/src/pages.json` |
**详细子项**
1. **S1 .env 凭据泄露修复**:安装 git-filter-repo、重写历史、添加 pre-commit 钩子
2. **S2 SQL 参数化**`logs.ts:167` LIMIT/OFFSET 改为 `?` 占位符 + parseInt 校验
3. **S3 TOKEN_SECRET 统一**:新建 `config/token.ts`,统一命名 + 强制启动校验 + 生成强密钥
4. **S4 下载凭证**:新建 `download_tokens` 表,改造 backup.ts / export.ts 下载逻辑
5. **S5 Refresh Token**:新建 `refresh_tokens` 表,双 Token 机制Access 2h + Refresh 30d前端 request.ts 401 自动刷新
6. **S6 软删除**`users` 表新增 `deleted_at`admin.ts DELETE → UPDATE新增恢复接口全局查询过滤
7. **S7 DB 连接校验**connection.ts 移除 fallback启动时校验必需环境变量
8. **基础设施**package.json 新增依赖jest, supertest, node-cron, vitest 等jest/vitest 配置,.env.test
### T02: 代码质量重构
| 字段 | 内容 |
|------|------|
| **任务 ID** | T02 |
| **优先级** | P1 |
| **描述** | 抽取通用组件消除重复代码ManageList/ColorPicker/EditModal补齐事务与异常处理统一工具函数与硬编码 URL |
| **依赖** | T01 |
| **涉及文件** | **新建**`client/src/components/ManageList/ManageList.vue`, `client/src/components/ColorPicker/ColorPicker.vue`, `client/src/components/EditModal/EditModal.vue`, `server/src/utils/upload.ts`, `client/src/utils/avatar.ts`, `client/src/static/iconfont/*`, `client/src/styles/iconfont.scss` |
| | **修改**`client/src/pages/category-manage/index.vue`, `client/src/pages/tag-manage/index.vue`, `client/src/components/Icon/Icon.vue`, `server/src/routes/recurring.ts`, `server/src/routes/user.ts`, `server/src/routes/notification.ts`, `client/src/utils/format.ts`, `client/src/config.ts` |
**详细子项**
1. **C2 ManageList 抽取**:新建 ManageList 组件category-manage 和 tag-manage 改为使用该组件,统一行为
2. **C2 ColorPicker 抽取**:从两个管理页面的弹窗中抽取颜色选择器
3. **C2 EditModal 抽取**:从两个管理页面的弹窗中抽取编辑弹窗
4. **C4 Recurring 事务**`recurring.ts /sync` 接口用 `getConnection() + beginTransaction()` 包裹
5. **C3 上传逻辑抽取**`user.ts``notification.ts` 的 multer 配置抽取为 `utils/upload.ts`
6. **C1 工具函数去重**:前端 `format.ts``getCurrentMonth` 与后端 `date.ts` 重复,前端保留并标注来源
7. **C7 硬编码 URL**:全局搜索 `http://``xiaocai.j35.site`,统一从 `config.ts` 读取
8. **C8 Icon → iconfont**生成字体图标Icon.vue 改为字体渲染
9. **C9 头像 URL 拼接**:抽取 `getAvatarUrl(filename)` 工具函数
### T03: 性能优化与 UX 增强
| 字段 | 内容 |
|------|------|
| **任务 ID** | T03 |
| **优先级** | P1 |
| **描述** | 实现乐观更新、并行加载、聚合接口、删除撤销、群组只读、离线记账、下拉刷新等性能与体验提升 |
| **依赖** | T01 |
| **涉及文件** | **新建**`client/src/components/Snackbar/Snackbar.vue`, `client/src/utils/offline.ts` |
| | **修改**`client/src/stores/category.ts`, `client/src/stores/tag.ts`, `client/src/stores/group.ts`, `client/src/stores/stats.ts`, `client/src/stores/notification.ts`, `client/src/stores/transaction.ts`, `client/src/stores/budget.ts`, `client/src/utils/request.ts`, `client/src/utils/app-ready.ts`, `client/src/utils/tracker.ts`, `client/src/components/TransactionItem/TransactionItem.vue`, `client/src/components/ChartWrapper/`, `client/src/pages/bills/index.vue`, `client/src/pages/budget/index.vue`, `client/src/pages/stats/index.vue`, `client/src/pages/add/index.vue`, `client/src/App.vue`, `server/src/routes/stats.ts`, `server/src/routes/track.ts`, `server/src/routes/budget.ts` |
**详细子项**
1. **Perf-1 乐观更新**category/tag Store 新增/编辑/排序操作先更新本地 state 再调接口,失败回滚
2. **Perf-2 并行加载**groupStore `refreshAll()` 中无依赖的 `fetchCategories()` + `fetchTags()` 改为 `Promise.all()`
3. **Perf-3 聚合接口**:后端 `GET /stats/dashboard``Promise.all` 并行查询 overview + category + trend
4. **Perf-4 批量 INSERT**:前端 tracker.ts 攒批500ms/10条后端 track.ts 接收数组批量 INSERT
5. **Perf-5 Admin N+1**admin.ts 用户列表改为 LEFT JOIN 单次查询
6. **Perf-6 前端缓存**category/tag Store 增加 `lastFetchTime`5 分钟内 onShow 跳过请求
7. **Perf-7 未读计数缓存**notification Store 缓存 30s
8. **UX-1 删除撤销**transaction Store 实现 pendingDelete Map + Snackbar 组件 + 3 秒超时
9. **UX-2 群组只读**TransactionItem 新增 `isReadOnly` prop非本人记录显示锁图标
10. **UX-3 waitForReady 超时**app-ready.ts 超时后 reject页面级 catch 显示错误提示
11. **UX-4 预算 group_id**budget Store/页面/路由 支持 group_id 参数
12. **UX-5 下拉刷新**pages.json 全局启用,各页面实现 onPullDownRefresh
13. **UX-6 离线记账**offline.ts 队列管理 + App.vue 网络监听 + add 页面适配
14. **UX-7 Loading 态**:异步操作添加 loading ref 守卫 + 按钮禁用
### T04: 测试体系建设
| 字段 | 内容 |
|------|------|
| **任务 ID** | T04 |
| **优先级** | P1 |
| **描述** | 搭建前后端测试框架,编写核心路由集成测试与 Store 单元测试,建立持续测试基线 |
| **依赖** | T01 |
| **涉及文件** | **新建**`server/tests/setup.ts`, `server/tests/auth.test.ts`, `server/tests/transaction.test.ts`, `server/tests/category.test.ts`, `server/tests/budget.test.ts`, `server/tests/stats.test.ts`, `client/tests/stores/transaction.test.ts`, `client/tests/stores/category.test.ts`, `client/tests/utils/format.test.ts`, `client/tests/utils/app-ready.test.ts` |
**详细子项**
1. **后端测试框架搭建**jest.config.js + setup.ts测试数据库初始化与清理
2. **auth 测试**:登录成功/失败、Token 生成与验证、Refresh Token 刷新、无效 Token 拒绝
3. **transaction 测试**CRUD、group_id 过滤、分页、金额精度、权限校验
4. **category 测试**CRUD、默认/自定义分类隔离、排序、迁移
5. **budget 测试**CRUD、月度预算、群组视图
6. **stats 测试**overview/category/trend/dashboard 查询、period 参数校验
7. **前端 Store 测试**transaction/category Store 核心方法测试
8. **工具函数测试**format.tsformatAmount、formatDate、getCurrentMonth、app-ready.ts
### T05: 新功能(财务报告 + 预算预警)
| 字段 | 内容 |
|------|------|
| **任务 ID** | T05 |
| **优先级** | P1 |
| **描述** | 实现周/月财务报告推送、预算超支预警、微信订阅消息集成 |
| **依赖** | T01 |
| **涉及文件** | **新建**`server/src/services/scheduler.ts`, `server/src/services/report.ts`, `server/src/services/budget-alert.ts`, `server/src/services/wechat-subscribe.ts`, `server/src/routes/report.ts`, `client/src/api/report.ts` |
| | **修改**`server/src/index.ts`, `server/src/routes/transaction.ts`, `server/src/routes/budget.ts` |
**详细子项**
1. **scheduler.ts**:使用 node-cron 注册定时任务(每周一 09:00 推送周报、每月 1 号 09:00 推送月报、每日凌晨全量预算扫描)
2. **report.ts**:查询指定时间范围的收支数据,生成摘要文本
3. **budget-alert.ts**:实现 checkBudgetAlert 逻辑80%/100%/120% 分级预警 + 去重
4. **wechat-subscribe.ts**:封装微信订阅消息发送接口
5. **report 路由**GET /reports/weekly、GET /reports/monthly
6. **用户设置**GET/PUT /user/settingsreport_push_enabled 开关
7. **触发集成**transaction.ts 创建记账后调用 budgetAlert.check()budget.ts 设置预算后调用 budgetAlert.check()
---
## 六、任务依赖图
```mermaid
graph TD
T01["T01: 项目基础设施与安全修复<br/>(P0 · S1-S7 + 依赖安装 + DB迁移)"]
T02["T02: 代码质量重构<br/>(P1 · C1-C9 + 通用组件)"]
T03["T03: 性能优化与 UX 增强<br/>(P1 · Perf-1~9 + UX-1~7)"]
T04["T04: 测试体系建设<br/>(P1 · T-1 + T-2 + T-4)"]
T05["T05: 新功能<br/>(P1 · F-1 + F-2)"]
T01 --> T02
T01 --> T03
T01 --> T04
T01 --> T05
style T01 fill:#FF6B6B,color:#fff
style T02 fill:#FFD700,color:#333
style T03 fill:#7BC67E,color:#fff
style T04 fill:#5B9BD5,color:#fff
style T05 fill:#8B5CF6,color:#fff
```
**并行策略**T01 完成后T02/T03/T04/T05 可并行推进。其中 T03 与 T02 有轻微文件冲突stores/),建议 T02 先行或约定合并策略。
---
## 七、依赖包列表
### 后端新增
```
- node-cron@^3.0.3 # 定时任务调度F-1 财务报告、F-2 预算扫描)
- jest@^29.7.0 # 后端测试框架T-1
- ts-jest@^29.1.1 # Jest TypeScript 支持
- supertest@^6.3.3 # HTTP 集成测试T-1
- @types/supertest@^6.0.2 # supertest 类型定义
```
### 前端新增
```
- vitest@^1.2.0 # 前端测试框架T-2
- @pinia/testing@^0.1.3 # Pinia 测试工具
- @vue/test-utils@^2.4.3 # Vue 组件测试工具
```
---
## 八、共享知识(跨文件约定)
### 8.1 API 响应格式
```typescript
// 所有 API 统一响应格式
{ code: 0, data: T, message?: string } // 成功
{ code: 40100, message: '未登录' } // Token 无效
{ code: 40101, message: 'token已过期' } // Token 过期(可刷新)
{ code: 40300, message: '无权访问' } // 权限不足
{ code: 40400, message: '资源不存在' } // 找不到
{ code: 42900, message: '请求过于频繁' } // 限流
{ code: 50000, message: '服务器错误' } // 服务端异常
```
### 8.2 认证相关
```
- Access Token 有效期2 小时HMAC-SHA256 签名格式base64(userId:timestamp:signature)
- Refresh Token 有效期30 天(随机 32 字节,存 SHA-256 hash
- 前端存储 Keyxc:accessToken, xc:refreshToken
- Authorization HeaderBearer <accessToken>
- 40101 表示 Token 过期可刷新40100 表示 Token 无效需重新登录
```
### 8.3 金额处理
```
- 所有金额以「分」为单位存储INT 类型)
- 前端展示使用 formatAmount() 转为 "¥ 1,234.56" 格式
- 输入时以「元」为单位,提交前乘以 100 转为分
- 金额校验1 <= amount <= 999999999
```
### 8.4 日期处理
```
- 数据库存储 DATE 类型YYYY-MM-DD
- 月份参数格式YYYY-MM如 "2026-06"
- 时区统一:服务器 + 客户端均按 UTC+8 处理
- getCurrentMonth() 前后端逻辑一致
```
### 8.5 软删除约定
```
- users 表使用 deleted_at 字段标记软删除
- 所有查询 users 表的 SQL 必须增加 WHERE deleted_at IS NULL
- 登录时检查 deleted_at IS NULL
- 群组成员查询需过滤已删除用户
- 已删除用户在群组统计中标注"已注销用户"
```
### 8.6 乐观更新约定
```
- 新增/编辑操作:先更新本地 state → 再调 API → 失败回滚 + toast
- 删除操作:仍等 API 确认后再移除(防误删)
- 排序操作:全量回滚(排序是完整 ID 数组操作)
- 临时 ID使用负数时间戳-Date.now()API 成功后替换为真实 ID
```
### 8.7 前端缓存约定
```
- 低频变更数据(分类/标签5 分钟缓存onShow 判断是否需要刷新
- 未读计数30 秒缓存
- 缓存失效:手动操作(新增/编辑/删除)后立即清除对应缓存
- 下拉刷新:强制清除缓存并重新请求
```
### 8.8 组件命名约定
```
- 组件目录client/src/components/ComponentName/ComponentName.vue
- Store 命名use{Name}Store
- API 模块client/src/api/{name}.ts
- 页面目录client/src/pages/{name}/index.vue
```
---
## 九、待明确事项
| # | 问题 | 影响范围 | 当前假设 | 风险 |
|---|------|----------|----------|------|
| Q1 | Refresh Token 存储方式 | S5 | 存 MySQL `refresh_tokens` 表;短中期可行,高并发时迁移 Redis | 当前写入频率极低MySQL 足够 |
| Q2 | 软删除级联范围 | S6 | 仅软删除用户账号,交易记录保留但通过 `deleted_at IS NULL` 过滤 | 保留交易可能影响群组统计,需标注"已注销用户" |
| Q3 | 删除撤销批量上限 | UX-1 | 上限 5 条Snackbar 最多显示"已删除 5 条记录"),超过直接删除 | 5 条覆盖绝大多数场景 |
| Q4 | 离线记账冲突策略 | UX-6 | MVP 仅支持离线新增(不涉及修改/删除),新增使用服务端生成 ID不存在主键冲突 | 如需离线编辑需引入版本号机制 |
| Q5 | 微信订阅消息模板审核 | F-1 | 优先站内通知,微信订阅消息作为增强项;需提前申请模板审核 | 微信审核可能被拒,需准备备选方案 |
| Q6 | 预算预警检查时机 | F-2 | 双重策略:记账后实时检查 + 每日凌晨全量扫描 | 仅实时检查可能遗漏周期记账触发的超支 |
| Q7 | 乐观更新回滚粒度 | Perf-1 | 排序操作全量回滚(语义清晰),新增/编辑单条回滚 | 全量回滚可能丢失已做的其他排序操作 |
| Q8 | 测试环境数据隔离 | T-1 | 独立测试数据库(.env.testCI 用 GitHub Actions service container | 本地需额外配置测试数据库 |
| Q9 | budgets 表 group_id 迁移 | UX-4 | 现有数据 group_id 默认 NULL个人预算UNIQUE KEY 从 (user_id, month) 改为 (user_id, group_id, month) | 迁移需确保不破坏现有预算数据 |
| Q10 | iconfont 图标清单 | C8 | 需从现有 Icon.vue 的 PNG 映射中提取完整图标列表,并在 iconfont.cn 重新制作 | 部分图标可能无现成 SVG需设计师配合 |
---
*文档版本: v2.0 | 最后更新: 2026-06-10*

212
docs/class-diagram.mermaid Normal file
View File

@@ -0,0 +1,212 @@
classDiagram
direction TB
class TokenConfig {
+TOKEN_SECRET: string
+ACCESS_TOKEN_EXPIRY: number
+REFRESH_TOKEN_EXPIRY: number
}
class AuthMiddleware {
+authMiddleware(req, res, next): void
-verifyHmacToken(token): userId
-isTokenExpired(timestamp): boolean
}
class AuthRoute {
+POST /login: LoginResult
+POST /demo-login: LoginResult
+POST /refresh: RefreshResult
-signToken(userId): string
-generateRefreshToken(userId): string
-hashToken(token): string
-autoSetAdmin(userId): void
}
class LoginResult {
+accessToken: string
+refreshToken: string
+expiresIn: number
+userId: number
+nickname: string
+avatar_url: string
+role: string
}
class RefreshResult {
+accessToken: string
+refreshToken: string
+expiresIn: number
}
class RefreshTokenRow {
+id: number
+user_id: number
+token_hash: string
+expires_at: Date
+revoked_at: Date|null
+created_at: Date
}
class DownloadTokenRow {
+id: number
+token: string
+user_id: number
+resource_type: string
+resource_id: string
+expires_at: Date
+used_at: Date|null
+created_at: Date
}
class DownloadTokenRoute {
+POST /download-tokens: GenerateDownloadToken
+GET /download/:token: FileStream
-validateToken(token): DownloadTokenRow
-markUsed(tokenId): void
-cleanupExpired(): void
}
class AdminRoute {
+GET /dashboard: DashboardData
+GET /users: UserList
+PUT /users/:id/status: void
+DELETE /users/:id: void~~软删除
+POST /users/:id/restore: void~~恢复
}
class UserRow {
+id: number
+openid: string
+nickname: string
+avatar_url: string
+role: string
+deleted_at: Date|null
+created_at: Date
}
class ManageListComponent {
+items: T[]
+displayField: string
+colorField: string
+showDrag: boolean
+showEdit: boolean
+showDelete: boolean
+emptyText: string
+emit_add(): void
+emit_edit(item): void
+emit_delete(item): void
+emit_sort(ids): void
}
class ColorPickerComponent {
+modelValue: string
+colors: string[]
+emit_update:modelValue(color): void
}
class EditModalComponent {
+visible: boolean
+title: string
+fields: EditField[]
+emit_confirm(data): void
}
class SnackbarComponent {
+message: string
+actionText: string
+duration: number
+visible: boolean
+emit_action(): void
+show(msg, action): void
+hide(): void
}
class OfflineQueue {
-pendingOps: PendingOp[]
-isProcessing: boolean
+enqueue(op): void
+dequeue(id): void
+processPendingOps(): Promise~void~
+isOnline(): boolean
+startNetworkListener(): void
}
class PendingOp {
+id: string
+type: string
+data: object
+createdAt: number
+status: string
}
class SchedulerService {
+start(): void
+stop(): void
-weeklyReportCron(): void
-monthlyReportCron(): void
-dailyBudgetScanCron(): void
}
class ReportService {
+generateWeeklyReport(userId): WeeklyReport
+generateMonthlyReport(userId): MonthlyReport
-queryPeriodData(userId, start, end): PeriodData
}
class BudgetAlertService {
+checkBudgetAlert(userId, month): void
-calculateUsageRatio(userId, month): number
-sendNotification(userId, level): void
-sendWechatMessage(userId, level): void
-hasAlerted(userId, month, level): boolean
}
class WechatSubscribeService {
+sendMessage(userId, templateId, data): void
-getAccessToken(): string
}
class BudgetAlertRow {
+id: number
+user_id: number
+month: string
+level: string
+group_id: number|null
+created_at: Date
}
class UserSettings {
+user_id: number
+report_push_enabled: boolean
+updated_at: Date
}
class StatsRoute {
+GET /overview: Overview
+GET /category: CategoryStat[]
+GET /trend: TrendPoint[]
+GET /dashboard: DashboardData
-buildWhereClause(userId, groupId): WhereResult
}
class DashboardData {
+overview: Overview
+category: CategoryStat[]
+trend: TrendPoint[]
}
TokenConfig <-- AuthMiddleware : uses
TokenConfig <-- AuthRoute : uses
AuthRoute --> LoginResult : returns
AuthRoute --> RefreshResult : returns
AuthRoute --> RefreshTokenRow : creates/revokes
DownloadTokenRoute --> DownloadTokenRow : creates/validates
AdminRoute --> UserRow : soft deletes
SchedulerService --> ReportService : triggers
SchedulerService --> BudgetAlertService : triggers
ReportService --> WechatSubscribeService : uses
BudgetAlertService --> WechatSubscribeService : uses
BudgetAlertService --> BudgetAlertRow : creates
BudgetAlertService --> UserSettings : checks
StatsRoute --> DashboardData : returns

254
docs/prd-alignment.md Normal file
View File

@@ -0,0 +1,254 @@
# 小菜记账 — 前后端对齐增量 PRD
> 版本: v1.0 | 日期: 2026-07-11
> 类型: 增量 PRD仅包含变更部分
> 基线: 前后端对齐分析发现的 8 项差距
---
## 一、产品目标
| # | 目标 | 说明 |
|---|------|------|
| G1 | 消灭前后端差距,实现已开发后端能力的前端落地 | 后端已有排序、备份、健康检查等端点,但前端缺失对应 UI |
| G2 | 补齐用户反馈闭环,提升产品可信度 | 用户提交反馈后无法查看回复,体验断裂 |
| G3 | 扩展数据维度与吞吐能力 | 支持年度/周度统计、服务端导出、数据导入,为数据量增长做准备 |
---
## 二、用户故事
### P0 — 分类拖拽排序
> As a 用户, I want 在分类管理页面拖拽调整分类顺序, so that 常用分类排在前面,记账时选得更快。
### P1 — 反馈回复展示
> As a 用户, I want 查看自己提交的反馈列表及管理员回复, so that 我知道反馈是否被处理以及处理结果。
### P1 — 备份管理页面
> As a 管理员, I want 在前端触发备份、查看备份列表并下载备份文件, so that 我可以在出现问题时快速恢复数据。
### P2 — 统计年度/周视图
> As a 用户, I want 在统计页面切换年/周/月维度, so that 我可以从不同时间粒度分析收支趋势。
### P2 — 数据导入与服务端导出
> As a 用户, I want 导入 JSON 格式的交易数据, so that 我可以从其他记账工具迁移历史数据。
>
> As a 用户, I want 大数据量导出走服务端生成, so that 导出速度快、不卡顿。
### P3 — 健康检查
> As a 管理员, I want 在管理页面看到后端服务运行状态, so that 我能第一时间发现服务异常。
### P3 — 交易标签系统
> As a 用户, I want 给交易添加标签(如"出差""聚餐", so that 我可以按标签筛选和统计关联交易。
>
> As a 用户, I want 按标签筛选交易列表和查看标签维度的统计, so that 我可以追踪特定场景的收支。
---
## 三、需求池
### P0 — Must Have
| ID | 需求 | 前端 | 后端 | 验收标准 |
|----|------|------|------|----------|
| P0-1 | 分类拖拽排序 | 分类管理页面添加拖拽排序 UI拖拽结束后调用 `sortCategories(ids)` | 无新增(`PUT /categories/sort` 已实现) | 拖拽后顺序持久化;刷新页面顺序保持;支出/收入分类各自排序 |
### P1 — Should Have
| ID | 需求 | 前端 | 后端 | 验收标准 |
|----|------|------|------|----------|
| P1-1 | 用户反馈列表 | 反馈页面新增"我的反馈"Tab展示反馈列表及管理员回复 | 新增 `GET /feedback/mine` 端点(用户维度,返回当前用户的反馈+`admin_reply` | 用户只能看自己的反馈;未回复显示"待处理"状态;已回复显示回复内容和时间 |
| P1-2 | 备份管理页面 | 新增备份管理页面:触发备份按钮、备份列表、下载按钮 | 新增 `GET /backup/:id/download` 端点 | 管理员可触发备份并看到进度;列表显示备份时间和大小;点击下载获取备份文件 |
### P2 — Nice to Have
| ID | 需求 | 前端 | 后端 | 验收标准 |
|----|------|------|------|----------|
| P2-1 | 统计年度/周视图 | 统计页面添加维度切换器(周/月/年),切换后图表和排行联动更新 | `GET /stats/category``GET /stats/trend` 支持 `period=week|month|year` 参数,按对应维度聚合数据 | 周视图显示本周每日数据;年视图显示每月汇总;切换时图表平滑过渡 |
| P2-2 | 数据导入 | 我的页面新增"数据导入"入口;支持 JSON 文件上传;导入前预览数据条数,确认后提交 | 新增 `POST /api/transactions/import` 端点,接收 JSON 文件,校验格式,批量写入 | 支持 JSON 格式;导入前展示条数预览;重复数据(同日期/金额/分类)跳过并提示;导入完成显示成功/跳过计数 |
| P2-3 | 数据导出服务端化 | 数据导出页面新增"服务端导出"选项;大数据量(>1000条自动走服务端 | 新增 `GET /api/export` 端点,支持 CSV/JSON 格式,流式响应 | 客户端导出保留作为轻量选项;服务端导出无条数限制;下载进度可感知 |
### P3 — Nice to Have远期
| ID | 需求 | 前端 | 后端 | 验收标准 |
|----|------|------|------|----------|
| P3-1 | 健康检查展示 | 管理页面新增服务状态卡片,显示后端健康信息 | 将 `/api/health` 端点移到 authMiddleware 之前 | 管理页面实时显示服务状态(正常/异常);无需登录即可调用健康检查 |
| P3-2 | 交易标签 — 标签管理 | 新增标签管理页面:创建/编辑/删除标签(名称+颜色) | 新增 `tags` 表及 CRUD 端点(`GET/POST/PUT/DELETE /api/tags` | 每个用户独立标签;标签不可重名;删除标签时解绑关联交易 |
| P3-3 | 交易标签 — 交易关联 | 记账页面新增"添加标签"入口;交易详情/编辑页可修改标签 | `transactions` 表新增关联;`POST/PUT /transactions` 支持 `tagIds` 字段 | 一笔交易可关联多个标签;标签选择器展示用户已有标签 |
| P3-4 | 交易标签 — 标签筛选统计 | 账单页支持按标签筛选;统计页新增标签维度视图 | `GET /transactions` 支持 `tagId` 筛选参数;`GET /stats/by-tag` 新端点 | 筛选结果仅显示含指定标签的交易;标签统计展示各标签下收支总额 |
---
## 四、UI 交互说明
### P0-1 分类拖拽排序
```
分类管理页面category-manage/index.vue变更
1. 页面顶部新增提示栏:"长按拖拽可调整分类顺序"
2. 每个分类项左侧显示拖拽手柄图标Lucide GripVertical, #BFB3B3
3. 长按分类项300ms进入拖拽模式
- 被拖拽项 elevation 提升shadow-clay-float
- 背景色变为 #FFF0E6
- 其余项轻微缩进让出空间
4. 拖拽过程中实时交换位置(带 150ms 过渡动画)
5. 松手后:
- 调用 sortCategories(ids) 提交新顺序
- 成功:轻量 toast "排序已保存"
- 失败:回滚到原位置 + 错误提示
6. 支出/收入 Tab 各自独立排序,互不影响
```
### P1-1 用户反馈列表
```
反馈页面feedback/index.vue变更
1. 页面顶部新增 Tab 切换:"提交反馈" | "我的反馈"
2. "我的反馈" Tab 内容:
├── 列表按提交时间倒序排列
├── 每项显示:
│ ├── 反馈内容截断2行点击展开
│ ├── 提交时间(相对时间:"3天前"
│ ├── 状态标签:
│ │ ├── 待处理 → 灰色 #BFB3B3
│ │ ├── 已回复 → 绿色 #7BC67E
│ │ └── 已关闭 → 橙色 #FF8C69
│ └── 管理员回复区域(仅已回复时显示):
│ ├── 分割线 + "管理员回复" 标签
│ ├── 回复内容
│ └── 回复时间
└── 空状态:插画 + "还没有提交过反馈"
3. 下拉刷新更新列表
4. 上拉加载更多(分页)
```
### P1-2 备份管理页面
```
新增页面backup-manage/index.vue
1. 顶部操作区:
├── [立即备份] 主按钮Clay 风格)
├── 点击后按钮变为 loading 态
└── 成功后列表顶部新增一条记录 + toast "备份成功"
2. 备份列表:
├── 按时间倒序
├── 每项显示:
│ ├── 备份时间2026-07-11 14:30
│ ├── 文件大小12.3 MB
│ └── [下载] 按钮Lucide Download 图标)
├── 点击下载:
│ ├── 按钮变为进度条
│ └── 完成后 toast "下载完成"
└── 空状态:"暂无备份记录"
3. 仅管理员角色可见此页面入口
```
### P2-1 统计年度/周视图
```
统计页面stats/index.vue变更
1. 月份选择器升级为维度切换器:
├── 新增三个 Tab周 | 月 | 年
├── 样式同现有支出/收入切换器
└── 切换后日期选择器联动:
├── 周:显示 "2026年7月 第2周",左右切换周
├── 月:保持现有行为
└── 年:显示 "2026年",左右切换年
2. 图表联动更新:
├── 周维度:趋势图 X 轴为周一~周日
├── 年维度:趋势图 X 轴为1月~12月
└── 环形图和排行同步更新为对应维度数据
3. 概览卡片数值联动
```
### P2-2 & P2-3 数据导入与导出
```
我的页面变更 + 新增页面:
1. "数据导出"入口变更:
├── 点击后进入导出页面
├── 导出方式选择:
│ ├── 客户端导出(< 1000 条推荐)— 现有逻辑
│ └── 服务端导出(≥ 1000 条推荐)— 新增
├── 格式选择CSV | JSON
├── 日期范围选择器
└── [开始导出] 按钮
2. 新增"数据导入"入口(我的页面功能列表):
├── 点击后进入导入页面
├── 步骤流程:
│ ├── Step 1选择文件仅 .json
│ ├── Step 2预览 — 显示识别条数、日期范围、分类映射
│ ├── Step 3确认导入
│ └── 结果:成功 N 条 / 跳过 M 条
└── 导入模板下载链接
```
### P3-1 健康检查展示
```
管理页面变更:
1. 页面顶部新增服务状态卡片:
├── 正常:绿色圆点 + "服务运行正常" + 响应时间 "23ms"
├── 异常:红色圆点 + "服务异常" + 错误信息
└── 检测中:灰色圆点 + 旋转图标
2. 每 30 秒自动刷新一次
3. 点击卡片可手动刷新
```
### P3-2~4 交易标签系统
```
全局变更:
1. 记账页面add/index.vue
├── 附加信息区新增"添加标签"行
├── 点击弹出标签选择器(底部弹窗):
│ ├── 已有标签列表(可多选)
│ ├── 搜索框
│ └── [新建标签] 快捷入口
└── 已选标签以药丸标签形式展示在输入区
2. 账单页面bills/index.vue
├── 筛选栏新增标签筛选
└── 筛选弹窗中增加标签选择
3. 统计页面stats/index.vue
└── 新增"标签统计"卡片(环形图 + 排行)
4. 我的页面:
└── 功能列表新增"标签管理"入口
5. 标签管理页面tag-manage/index.vue— 新增:
├── 标签列表(名称 + 颜色色块 + 关联交易数)
├── [新建标签] 按钮
├── 编辑弹窗:名称输入 + 颜色选择器8色预设
└── 删除确认:关联 N 笔交易时提示"将解除关联"
```
---
## 五、待确认问题
| # | 问题 | 影响范围 | 建议 |
|---|------|----------|------|
| Q1 | 分类拖拽排序是否需要动画反馈?拖拽手柄位置(左侧还是右侧)? | P0-1 | 建议左侧手柄 + 长按触发,符合主流交互习惯 |
| Q2 | 用户反馈列表是否需要通知推送?当管理员回复后用户是否收到微信服务通知? | P1-1 | 后端已有通知逻辑,但微信订阅消息需用户主动订阅,建议先做页面内展示,通知作为后续增强 |
| Q3 | 备份文件的下载鉴权方式URL 是否需要时效性签名? | P1-2 | 建议使用临时签名 URL有效期 1 小时),避免备份文件被猜测 URL 直接访问 |
| Q4 | 统计年度视图的趋势图粒度:按月汇总还是按季汇总? | P2-1 | 建议按月汇总12 个点),季度可通过后续分组实现 |
| Q5 | 数据导入的 JSON 格式规范?是否需要支持其他记账 App 的导出格式? | P2-2 | 建议先定义小菜记账自有 JSON 格式,提供模板下载;其他 App 格式兼容作为远期需求 |
| Q6 | 服务端导出的文件存储方式:内存生成直接流式返回 vs 先写文件再下载? | P2-3 | 建议 < 1 万条内存流式返回,≥ 1 万条先写临时文件再下载,避免内存溢出 |
| Q7 | 交易标签上限:每笔交易最多关联几个标签?每个用户最多创建多少个标签? | P3-2~4 | 建议每笔交易最多 5 个标签,每用户最多 20 个标签,后续可调整 |
| Q8 | 标签与分类的关系:标签是否可以跨分类?同一标签能否同时用于支出和收入? | P3-2~4 | 建议标签不区分收支类型,跨分类使用,这是标签区别于分类的核心价值 |
---
*文档版本: v1.0 | 最后更新: 2026-07-11*

873
docs/prd-iteration-v2.md Normal file
View File

@@ -0,0 +1,873 @@
# 小菜记账 — 全量迭代 PRD v2
> 版本: v2.0 | 日期: 2026-06-10
> 类型: 全量迭代 PRD安全性 · 代码质量 · 性能 · UX · 可测试性 · 新功能)
> 基线: 已完成三轮迭代(代码审查修复 · UI 优化 · 前后端功能对齐 8 项差距)
> 范围: 本次审查发现 35 项改进点,覆盖 6 个维度
---
## 一、产品目标
| # | 目标 | 说明 | 关联维度 |
|---|------|------|----------|
| G1 | 消除生产环境安全风险 | .env 泄露、SQL 注入、弱密钥、Token 暴露等 P0/P1 安全问题必须在上线前全部清零 | 安全性 |
| G2 | 建立可持续的代码质量基线 | 消除重复代码、补齐事务与异常处理、统一规范,使后续迭代不会因技术债减速 | 代码质量 |
| G3 | 核心路径性能感知提升 50% | 首页加载、分类/标签操作、统计查询等高频路径的响应时间减半 | 性能优化 |
| G4 | 交互体验达到主流记账 App 水准 | 删除撤销、群组只读、离线记账、下拉刷新等缺失体验补齐 | 用户体验 |
| G5 | 建立测试体系与核心功能扩展 | 从零测试覆盖到关键路径有保障,并新增财务报告与预算预警等高价值功能 | 可测试性 · 新功能 |
---
## 二、用户故事
### P0 — 安全紧急
> **S1** As a 产品负责人, I want .env 文件中的生产凭据从 Git 历史中彻底清除, so that 攻击者无法通过代码仓库获取数据库密码和微信密钥。
> **S2** As a 用户, I want 后端所有 SQL 查询使用参数化方式, so that 我的财务数据不会被 SQL 注入攻击窃取。
> **S3** As a 用户, I want 认证 Token 使用强密钥且命名统一, so that 伪造 Token 攻击无法成功。
### P1 — 安全高优
> **S4** As a 用户, I want 导出/备份下载链接使用一次性短期凭证, so that 我的财务数据不会被 URL 泄露导致未授权访问。
> **S5** As a 用户, I want 登录 Token 支持轮换机制, so that 即使 Token 泄露也能在短时间内失效。
> **S6** As a 管理员, I want 删除用户时使用软删除并需二次确认, so that 不会因误操作导致用户数据永久丢失。
> **S7** As a 运维人员, I want 数据库连接不硬编码默认凭据, so that 未配置环境变量时服务直接报错而非以弱凭据连接。
### P1 — 代码质量
> **C1** As a 开发者, I want 消除前后端重复的工具函数, so that 修改逻辑时不需要同步多处代码。
> **C2** As a 开发者, I want tag-manage 与 category-manage 共用可复用组件, so that 两个管理页面行为一致且维护成本低。
> **C4** As a 用户, I want 周期记账同步操作在数据库事务中执行, so that 部分失败不会产生脏数据。
> **C5** As a 开发者, I want Store 的异步操作都有 try/catch, so that 未捕获异常不会导致界面卡死。
> **C7** As a 开发者, I want 所有 API 地址从 config.ts 统一读取, so that 环境切换不会遗漏。
### P1 — 性能优化
> **Perf-1** As a 用户, I want 添加/编辑分类后列表立即更新而无需等待接口返回, so that 操作反馈无延迟感。
> **Perf-2** As a 用户, I want 统计页面的多个接口并行请求, so that 页面加载不会串行等待。
### P1 — 用户体验
> **UX-1** As a 用户, I want 删除记录后 3 秒内可以撤销, so that 误删不会导致数据永久丢失。
> **UX-2** As a 群组成员, I want 看到非本人记录时显示只读标记, so that 我不会误编辑他人记录。
> **UX-3** As a 用户, I want 预算设置支持群组维度, so that 群组场景下也能设置和跟踪预算。
### P1 — 可测试性
> **T-1** As a 开发者, I want 后端路由有集成测试覆盖, so that 接口变更不会静默破坏功能。
### P1 — 新功能
> **F-1** As a 用户, I want 每周/月收到财务报告推送, so that 我不用主动打开 App 也能了解收支状况。
> **F-2** As a 用户, I want 预算达到 80%/100%/120% 时收到预警通知, so that 我能及时控制支出。
---
## 三、需求池
### P0 — Must Have安全紧急阻塞上线
| ID | 维度 | 需求 | 影响范围 | 验收标准 |
|----|------|------|----------|----------|
| S1 | 安全 | .env 凭据泄露修复 | 全局 | `.env``.gitignore`已有但需验证Git 历史中不含任何凭据明文;所有已泄露密钥已轮换 |
| S2 | 安全 | logs.ts SQL 拼接修复 | `server/routes/logs.ts` | `LIMIT ${pSize} OFFSET ${offset}` 改为 `pool.query` + 参数占位符 `?`;或改为 `parseInt` + 范围校验后内插 |
| S3 | 安全 | TOKEN_SECRET 弱默认值 + 命名不一致 | `server/middleware/auth.ts`, `server/routes/auth.ts`, `server/routes/backup.ts` | 统一为 `TOKEN_SECRET`;移除所有 fallback 默认值,未配置时进程拒绝启动 |
### P1 — Should Have
#### 安全
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| S4 | Export/Backup URL 传认证 token → 短期一次性下载凭证 | `server/routes/export.ts`, `server/routes/backup.ts` | 下载凭证有效期 5 分钟,一次性使用后失效;不使用 query string 传长期 Token |
| S5 | Refresh Token 机制 | `server/routes/auth.ts`, `server/middleware/auth.ts`, `client/src/utils/request.ts` | Access Token 有效期缩短至 2hRefresh Token 有效期 30dRefresh Token 支持轮换(用后旧 Token 失效) |
| S6 | Admin 硬删除 → 软删除 + 二次确认 | `server/routes/admin.ts`, DB schema | `users` 表新增 `deleted_at` 字段;删除操作改为 `SET deleted_at = NOW()`;需前端二次确认弹窗;查询自动过滤 `deleted_at IS NULL` |
| S7 | DB 连接移除默认凭据 | `server/src/db/connection.ts` | 移除 `|| 'localhost'``|| 'xiaocai'``|| 'xiaocai123'` 等 fallback未配置时 `createPool` 抛出明确错误 |
#### 代码质量
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| C1 | 重复工具函数抽取 | 前后端 `utils/` | 识别并合并功能相同的工具函数(如日期格式化、金额处理);抽取为共享模块或分别去重 |
| C2 | tag-manage / category-manage 重复代码 | 前端页面 | 抽取 `ManagePageLayout` 可复用组件(列表 + 新增 + 编辑 + 删除 + 拖拽排序);两页面基于该组件定制 |
| C4 | Recurring 同步缺事务 | `server/routes/recurring.ts` | `/sync` 接口使用 `pool.getConnection()` + `connection.beginTransaction()` 包裹;部分失败时 `rollback()` |
| C5 | Store 缺 try/catch | `client/src/stores/*.ts` | 所有 Store 中 `await api.xxx()` 调用包裹 try/catchcatch 中使用统一错误提示toast不吞错误 |
| C7 | 硬编码 URL | 前端全局 | 全局搜索 `http://``xiaocai.j35.site`,替换为 `config.ts` 导出的变量 |
#### 性能优化
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| Perf-1 | 分类/标签乐观更新 | `client/stores/category.ts`, `client/stores/tag.ts` | 新增/编辑/排序操作先更新本地 state 再调接口;失败时回滚并提示;删除仍等接口确认后移除 |
| Perf-2 | groupStore 并行加载 | `client/stores/group.ts` | `refreshAll()` 中多个 `fetchXxx()` 改为 `Promise.all()` 并行执行 |
| Perf-3 | Stats 聚合接口 | `server/routes/stats.ts` | 合并 `/stats/overview` + `/stats/category` + `/stats/trend` 为单一 `/stats/dashboard` 接口;减少 3 次请求为 1 次 |
| Perf-4 | Track 批量 INSERT | `server/routes/track.ts` | 多条埋点数据合并为单条 `INSERT INTO ... VALUES (?,?), (?)` 批量写入 |
| Perf-5 | Admin N+1 查询 | `server/routes/admin.ts` | 用户列表查询改为 JOIN 单次获取关联数据,消除循环内查询 |
| Perf-6 | 前端数据缓存 | `client/stores/*.ts` | 分类、标签等低频变更数据增加内存缓存标记,`onShow` 时判断是否需刷新 |
| Perf-7 | 未读计数缓存 | `client/stores/notification.ts` | 未读计数结果缓存 30s避免每次 `onShow` 都请求 |
#### 用户体验
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| UX-1 | 删除撤销 | `client/pages/bills/`, `client/stores/transaction.ts` | 删除后底部弹出 Snackbar3 秒内可点击"撤销"恢复;撤销成功数据不变;超时后真正删除 |
| UX-2 | 群组只读标记 | `client/pages/bills/`, `client/components/TransactionItem/` | 非本人记录显示小锁图标;点击进入查看模式而非编辑模式;查看模式下字段不可修改 |
| UX-3 | waitForReady 超时处理 | `client/utils/app-ready.ts` | 超时后 reject 并在页面级捕获,展示"网络异常,请检查网络后重试"提示;不再静默继续 |
| UX-4 | 预算 group_id | `server/routes/budget.ts`, `client/stores/budget.ts`, `client/pages/budget/` | 预算设置/查询支持 `group_id` 参数;群组视图下预算卡片显示群组总预算 + 我的份额 |
#### 可测试性
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| T-1 | 后端路由集成测试 | `server/` | 使用 Jest + supertest覆盖 auth、transaction、category、budget 核心 CRUD 路由;断言状态码 + 返回格式 + 权限校验 |
#### 新功能
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| F-1 | 月度/周度财务报告推送 | 后端定时任务 + 微信订阅消息 | 每周一早 9 点推送上周收支摘要;每月 1 号推送上月收支报告;使用微信订阅消息模板;用户可在设置中开关 |
| F-2 | 预算超支预警 | 后端定时任务 + 通知系统 | 80% 时站内通知提醒100% 时站内通知 + 微信订阅消息120% 时站内紧急通知 + 微信订阅消息;预算检查在记账后实时触发 |
### P2 — Nice to Have
#### 代码质量
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| C3 | 上传重复逻辑抽取 | `server/routes/user.ts`, `server/routes/notification.ts` | 抽取 `handleUpload(dir, file)` 公共函数,统一 MIME 校验 + 大小限制 + 文件名生成 |
| C6 | Store 吞错误修复 | `client/stores/*.ts` | catch 块中至少 `console.error` + 用户可见 toast 提示;不再空 catch |
| C8 | Icon 组件 → iconfont | `client/components/Icon/` | 将 PNG 图标映射替换为 iconfont 字体图标;减小包体积;支持动态颜色 |
| C9 | 头像 URL 拼接重复 | 前端多处 | 抽取 `getAvatarUrl(filename)` 工具函数,统一拼接 `API_BASE + 路径 + 文件名` |
#### 性能优化
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| Perf-8 | Logs 流式读取 | `server/routes/logs.ts` | 大量日志数据使用 stream 分批返回,避免一次性加载到内存 |
| Perf-9 | ChartWrapper 延迟渲染 | `client/components/ChartWrapper/` | 图表组件进入可视区域后才开始渲染IntersectionObserver非可视区域显示占位 |
#### 用户体验
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| UX-5 | 下拉刷新统一 | 前端所有数据页面 | 所有列表页面启用 `onPullDownRefresh`;统一在 `pages.json` 配置 `enablePullDownRefresh` |
| UX-6 | 离线支持 | `client/` 全局 | 记账操作存入本地队列 + 网络状态监听;网络恢复后自动同步;离线期间操作标记为"待同步"状态 |
| UX-7 | Loading 态统一 | 前端全局 | 所有异步操作添加 loading 态(骨架屏 / spinner / 按钮禁用);操作期间禁止重复提交 |
#### 可测试性
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| T-2 | Composables 测试 | `client/` | 抽取可复用逻辑为 composables使用 Vitest 编写单元测试 |
| T-3 | 依赖注入 | `client/` | API 调用通过 provide/inject 注入,便于测试时 mock |
#### 新功能
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| F-3 | 多设备同步 | 后端 + 前端 | 基于 `updated_at` 时间戳的增量同步;冲突策略:后写入优先;同步状态可视化 |
| F-4 | AA 分账 | 后端 + 前端 | 群组内选择多笔交易,按人均/自定义比例分摊;生成每人应付/应收金额;支持结算确认 |
| F-5 | 智能记账 | 后端 + 前端 | 备注关键词自动匹配分类(如"滴滴"→交通);历史金额学习建议;最近使用分类优先 |
### P3 — 远期规划
#### 代码质量
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| C10 | 路由中间件一致性 | `server/routes/*.ts` | 统一所有路由的中间件注册方式(统一在 `index.ts` 注册还是路由内自注册) |
#### 性能优化
| ID | 需求 | 影响范围 | 验收标准 |
|----|------|----------|----------|
| Perf-10 | 数据可视化增强 | `client/pages/stats/` | 支持更丰富的图表类型(饼图、折线图、柱状图);支持图表数据导出为图片 |
---
## 四、安全修复方案
### S1.env 凭据泄露修复
**当前状态**`.env` 已在 `.gitignore` 中,但文件内容已在历史提交中暴露,包含数据库地址 `115.120.243.74`、密码 `dnjwYbpMdmASCxfH`、微信 AppID/Secret。
**修复步骤**
1. **立即轮换所有已泄露凭据**
- MySQL: 修改 `xiaocai_test` 用户密码
- 微信: 在微信公众平台重置 AppSecret
- TOKEN_SECRET: 生成新的强随机密钥(`openssl rand -hex 32`
2. **清除 Git 历史**
```bash
# 使用 git-filter-repo推荐比 BFG 更安全)
pip install git-filter-repo
git filter-repo --path server/.env --invert-paths
git filter-repo --blob-callback 'blob.data = blob.data.replace(b"dnjwYbpMdmASCxfH", b"REDACTED")'
```
3. **强制推送并通知协作者**
```bash
git push origin --force --all
```
4. **验证 `.gitignore` 生效**
```bash
git check-ignore server/.env # 应输出 server/.env
```
5. **添加 pre-commit hook 防止再次提交**
```bash
# .githooks/pre-commit
if git diff --cached --name-only | grep -q '\.env'; then
echo "ERROR: .env files must not be committed"
exit 1
fi
```
**回滚方案**Git 历史重写前创建完整备份 `git clone --mirror`。
### S2logs.ts SQL 拼接修复
**当前状态**`server/routes/logs.ts:160-168` 使用模板字符串拼接 LIMIT/OFFSET
```typescript
// ❌ 当前代码
LIMIT ${pSize} OFFSET ${offset}
```
**修复方案**
```typescript
// ✅ 方案 A参数化查询推荐
const safeLimit = Math.min(Math.max(parseInt(String(pSize)) || 10, 1), 100)
const safeOffset = Math.max(parseInt(String(offset)) || 0, 0)
const [rows] = await pool.query(
`SELECT t.*, u.nickname
FROM track_events t
LEFT JOIN users u ON t.user_id = u.id
WHERE ${where}
ORDER BY t.created_at DESC
LIMIT ? OFFSET ?`,
[...params, safeLimit, safeOffset]
)
```
**关键点**`LIMIT`/`OFFSET` 的值必须先 `parseInt` + 范围校验,防止非整数或超大值;`pool.query` 支持 `?` 占位符用于整数参数(与 `pool.execute` 的 prepared statement 不同)。
### S3TOKEN_SECRET 弱默认值 + 命名不一致
**当前状态**
- `auth.ts` / `middleware/auth.ts``process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'`
- `backup.ts``process.env.JWT_SECRET || 'xiaocai-secret'`(命名不一致 + 弱默认值)
- `.env` 中:`TOKEN_SECRET=xiaocai-prod-secret-2026-change-me`(弱密钥)
**修复方案**
1. **统一命名为 `TOKEN_SECRET`**
- `backup.ts` 中 `JWT_SECRET` → `TOKEN_SECRET`
- 删除 `const JWT_SECRET = ...` 声明,改为从 `middleware/auth.ts` 导出或统一读取 `process.env.TOKEN_SECRET`
2. **移除所有 fallback 默认值,强制要求环境变量**
```typescript
// server/src/config/token.ts新增
export const TOKEN_SECRET = process.env.TOKEN_SECRET
if (!TOKEN_SECRET || TOKEN_SECRET.length < 32) {
console.error('[Security] TOKEN_SECRET must be set and at least 32 characters')
process.exit(1)
}
```
3. **生成强密钥**
```bash
# 在服务器上生成并写入 .env
echo "TOKEN_SECRET=$(openssl rand -hex 32)" >> server/.env
```
4. **所有引用处改为**
```typescript
import { TOKEN_SECRET } from '../config/token'
```
### S4Export/Backup URL 短期一次性下载凭证
**当前状态**
- `backup.ts`:下载链接使用 HMAC 签名的 `backupId:timestamp:hmac` 格式 token通过 query string 传递
- `export.ts`:导出接口支持 query string 传 token 兼容
**问题**:虽然已有 HMAC 签名但无过期时间校验backup 有 timestamp 但未校验时效),且 token 可重复使用。
**修复方案**
1. **新增 `download_tokens` 表**
```sql
CREATE TABLE download_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) NOT NULL UNIQUE,
user_id INT NOT NULL,
resource_type ENUM('backup', 'export') NOT NULL,
resource_id VARCHAR(100),
expires_at DATETIME NOT NULL,
used_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token),
INDEX idx_expires (expires_at)
);
```
2. **生成下载凭证**
```typescript
// 有效期 5 分钟
const token = randomBytes(32).toString('hex')
await pool.query(
'INSERT INTO download_tokens (token, user_id, resource_type, resource_id, expires_at) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE))',
[token, userId, 'backup', backupId]
)
```
3. **验证下载凭证**
```typescript
const [rows] = await pool.query(
'SELECT * FROM download_tokens WHERE token = ? AND user_id = ? AND expires_at > NOW() AND used_at IS NULL',
[token, userId]
)
if (!rows.length) return res.status(403).json({ code: 40300, message: '下载凭证无效或已过期' })
// 标记为已使用
await pool.query('UPDATE download_tokens SET used_at = NOW() WHERE id = ?', [rows[0].id])
```
4. **定期清理过期 token**:在 health check 或定时任务中清理 `expires_at < NOW()` 的记录。
### S5Refresh Token 机制
**当前状态**:单 Token 机制HMAC-SHA256 签名30 天过期,无轮换。
**修复方案**
1. **双 Token 机制**
- Access Token有效期 2 小时,用于 API 调用
- Refresh Token有效期 30 天,仅用于刷新 Access Token
2. **新增 `refresh_tokens` 表**
```sql
CREATE TABLE refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL,
expires_at DATETIME NOT NULL,
revoked_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_hash (token_hash)
);
```
3. **认证流程**
- 登录:返回 `accessToken` + `refreshToken`
- API 调用Header 带 `Authorization: Bearer <accessToken>`
- Token 过期401前端用 `refreshToken` 调用 `/auth/refresh` 获取新 `accessToken` + 新 `refreshToken`(旧 refresh token 失效)
- Refresh Token 也过期:需重新登录
4. **前端适配**`client/src/utils/request.ts`
- 现有 401 重登录机制改为 401 → refresh → 重试
- refresh 也失败401→ 清除本地 token → 跳转登录
5. **安全措施**
- Refresh Token 存储 hash 值SHA-256不存明文
- 每个 Refresh Token 仅使用一次(用后旧 token 失效)
- 用户修改密码时撤销所有 Refresh Token
### S6Admin 硬删除 → 软删除 + 二次确认
**当前状态**`admin.ts:170` 直接 `DELETE FROM users WHERE id = ?`,无二次确认。
**修复方案**
1. **DB Schema 变更**
```sql
ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL;
CREATE INDEX idx_users_deleted ON users(deleted_at);
```
2. **后端修改**
```typescript
// 软删除
router.delete('/users/:id', async (req, res) => {
await pool.query('UPDATE users SET deleted_at = NOW() WHERE id = ?', [req.params.id])
res.json({ code: 0, data: { message: '用户已禁用' } })
})
// 恢复
router.post('/users/:id/restore', async (req, res) => {
await pool.query('UPDATE users SET deleted_at = NULL WHERE id = ?', [req.params.id])
res.json({ code: 0, data: { message: '用户已恢复' } })
})
```
3. **全局查询过滤**:在 `auth.ts` 登录时检查 `deleted_at IS NULL`;其他查询同理。
4. **前端二次确认**
```
管理员点击"删除用户" → 弹出确认弹窗:
"确定要禁用用户「{nickname}」吗?该操作将冻结该用户的所有数据,但不会删除记录。"
[取消] [确认禁用]
```
### S7DB 连接移除默认凭据
**当前状态**`connection.ts` 中 `password: process.env.DB_PASSWORD || 'xiaocai123'` 等硬编码默认值。
**修复方案**
```typescript
// ✅ 修复后
const required = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'] as const
for (const key of required) {
if (!process.env[key]) {
console.error(`[DB] Missing required environment variable: ${key}`)
process.exit(1)
}
}
const pool = mysql.createPool({
host: process.env.DB_HOST!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
})
```
---
## 五、UI 交互说明
### UX-1删除撤销
```
交互流程:
1. 用户在账单列表左滑删除某条记录
2. 记录从列表中移除(动画:向左滑出)
3. 底部弹出 Snackbar
┌───────────────────────────────────────────┐
│ 🗑 已删除 1 条记录 [撤销] │
└───────────────────────────────────────────┘
- 背景色: $surface (#FFFFFF)
- 文字色: $text-sec (#8B7E7E)
- "撤销"按钮: $primary (#FF8C69) + 500 字重
- 自动消失时间: 3 秒
- 从底部上滑动画: 200ms ease-out
4. 3 秒内点击"撤销"
- 记录重新插入列表原位置(动画:从左侧滑入)
- 不调用后端删除接口
- Snackbar 消失
5. 3 秒超时未操作:
- Snackbar 下滑消失
- 调用后端 DELETE /transactions/:id
- 如果接口失败,记录恢复到列表 + 错误提示
实现要点:
- 删除操作先只移除前端列表项,不立即调接口
- 使用 pendingDelete Map 存储待删除记录id → 原始数据 + 原位置索引)
- 多次快速删除时 Snackbar 累加计数:"已删除 N 条记录"
- 撤销时按 LIFO 顺序恢复
```
### UX-2群组只读标记
```
交互设计:
1. TransactionItem 组件变更:
- 新增 props: `isReadOnly: boolean`
- 非本人记录transaction.user_id !== currentUserId
├── 右上角显示小锁图标Lucide Lock, 16rpx, $text-sec 颜色)
├── 左滑不显示删除按钮
└── 整体透明度降为 0.85(区分视觉层级)
2. 点击交互:
- 本人记录:点击 → 编辑页面(现有行为)
- 非本人记录:点击 → 查看页面(只读模式)
├── 页面布局与编辑页相同
├── 所有输入框/选择器为 disabled 态
├── 金额/分类/标签仅展示不可修改
├── 底部无"保存"按钮
└── 顶部标题显示"查看记录"
3. 群组视图下的记账按钮:
- 仍然可以新增自己的记录
- 新增记录自动归属当前用户
```
### UX-3waitForReady 超时处理
```
交互流程:
1. 页面 onMounted 调用 await waitForReady()
2. 如果 8 秒内未就绪:
- waitForReady() reject而非当前 resolve
- 页面 catch 中显示全屏错误状态:
┌─────────────────────────┐
│ │
│ ⚠️ 网络连接异常 │
│ 请检查网络后点击重试 │
│ │
│ [重新加载] │
│ │
└─────────────────────────┘
- "重新加载"按钮点击后重新执行 waitForReady() + loadData()
- 不再静默以未登录状态加载数据
```
### UX-4预算 group_id 支持
```
交互设计:
1. 预算页面budget/index.vue变更
- 个人视图:显示"我的月度预算"(现有行为不变)
- 群组视图:显示两个卡片:
├── 群组总预算卡片:所有成员预算之和 + 群组总支出
│ └── "群组总预算 ¥ 8,000.00"
└── 我的预算卡片:当前用户个人预算 + 个人支出
└── "我的预算 ¥ 3,000.00"
2. 设置预算弹窗变更:
- 个人视图:设置个人月度预算
- 群组视图:设置"我在群组中的月度预算"
- 弹窗标题根据视图切换
3. 预算进度条变更:
- 个人视图:支出/预算(现有)
- 群组视图:
├── 总进度条:群组总支出 / 群组总预算
└── 我的进度条:我的支出 / 我的预算(子进度条样式)
```
### UX-5下拉刷新统一
```
实现规范:
1. pages.json 全局配置:
"globalStyle": {
"enablePullDownRefresh": true
}
2. 所有数据页面统一模式:
onPullDownRefresh(async () => {
await loadData()
uni.stopPullDownRefresh()
})
3. 需要启用下拉刷新的页面:
- 首页 index
- 账单页 bills
- 统计页 stats
- 通知中心 notifications
- 群组管理 group-manage
- 标签管理 tag-manage
4. 不启用下拉刷新的页面:
- 表单页add, profile-edit, budget
- 设置页
```
### UX-6离线支持
```
架构设计:
1. 操作队列localStorage
- Key: `xc:pendingOps`
- 数据结构:
[
{ id: 'uuid', type: 'create', data: {...}, createdAt: timestamp, status: 'pending' },
{ id: 'uuid', type: 'update', data: {...}, createdAt: timestamp, status: 'pending' }
]
2. 网络状态监听:
onMounted(() => {
uni.onNetworkStatusChange(({ isConnected }) => {
if (isConnected) processPendingOps()
})
})
3. 操作执行流程:
- 有网络:正常调接口
- 无网络:
├── 写入操作队列
├── 本地 state 立即更新(乐观更新)
├── 记录标记为"待同步"状态(右下角小图标)
└── Toast"已保存,将在网络恢复后同步"
4. 同步流程processPendingOps
- 按时间顺序逐条执行
- 全部成功:清除队列 + toast "同步完成"
- 部分失败:标记失败项 + toast "N 条同步失败" + 保留在队列中重试
5. 限制范围:
- MVP 阶段仅支持"新增记账"离线操作
- 编辑/删除等操作离线时禁用(提示"请连接网络后操作"
```
### UX-7Loading 态统一
```
规范:
1. 页面级加载:使用 Skeleton 骨架屏组件
- 首次加载 onMounted 时显示
- 下拉刷新不显示骨架屏(数据已存在)
2. 操作级加载:按钮/操作区域 loading 态
- 按钮点击后 disabled + 显示 spinner
- 防止重复提交debounce 300ms 或 loading ref 守卫)
3. 全局 loading
- 页面切换时的导航栏 loadinguni.showNavigationBarLoading
- 长时间操作(导出/备份)使用全屏 loading + 进度提示
4. 空状态:
- 数据为空时显示空状态插画 + 引导文案
- 与加载中状态明确区分(不是空白页)
```
---
## 六、性能优化方案
### Perf-1分类/标签乐观更新
| 项目 | 说明 |
|------|------|
| 当前问题 | 新增/编辑/排序分类后,等待接口返回才更新列表,用户感知延迟 200-500ms |
| 优化策略 | 先更新本地 Pinia state再异步调接口接口失败时回滚 state + toast 提示 |
| 实现方式 | Store 方法内部:先 `state.items.push(newItem)` → `await api.create()` → 失败则 `state.items.pop()` + `toast('操作失败')` |
| 预期效果 | 操作感知延迟从 200-500ms 降至 < 16ms一帧 |
| 适用范围 | 新增分类/标签、编辑分类/标签、拖拽排序;删除操作仍等接口确认(防误删) |
### Perf-2groupStore 并行加载
| 项目 | 说明 |
|------|------|
| 当前问题 | `refreshAll()` 中 `fetchCategories()` → `fetchTags()` → `fetchTransactions()` 串行执行,总耗时 = 3 次请求之和 |
| 优化策略 | 无依赖的请求改为 `Promise.all()` 并行执行 |
| 实现方式 | `await Promise.all([fetchCategories(), fetchTags()]); await fetchTransactions()`transactions 依赖 categories 的映射) |
| 预期效果 | 并行部分耗时 = max(单次请求),总体减少 40-60% |
### Perf-3Stats 聚合接口
| 项目 | 说明 |
|------|------|
| 当前问题 | 统计页加载时串行调用 `/stats/overview` + `/stats/category` + `/stats/trend`3 次 HTTP 往返 |
| 优化策略 | 合并为单一 `/stats/dashboard` 接口,后端并行查询后一次性返回 |
| 实现方式 | 后端新路由中 `Promise.all([queryOverview, queryCategory, queryTrend])` → 返回 `{ overview, category, trend }` |
| 预期效果 | 3 次 HTTP 往返 → 1 次;减少约 200ms 网络开销 |
### Perf-4Track 批量 INSERT
| 项目 | 说明 |
|------|------|
| 当前问题 | 前端一次页面访问可能触发多条埋点,每条单独 INSERTN 条 = N 次数据库写入 |
| 优化策略 | 前端批量发送,后端使用 `INSERT INTO ... VALUES (?), (?), (?)` 批量写入 |
| 实现方式 | 前端 `tracker.ts` 攒批500ms 或 10 条),批量 POST后端 `track.ts` 接收数组后构建批量 INSERT |
| 预期效果 | 10 条埋点从 10 次 INSERT 降至 1 次,减少数据库连接开销 90% |
### Perf-5Admin N+1 查询
| 项目 | 说明 |
|------|------|
| 当前问题 | 用户列表查询后,循环每个用户查询其交易数/群组数等信息N 个用户 = 1 + N 次查询 |
| 优化策略 | 使用 LEFT JOIN + GROUP BY 在一次查询中获取所有关联数据 |
| 实现方式 | `SELECT u.*, COUNT(DISTINCT t.id) as tx_count, COUNT(DISTINCT gm.group_id) as group_count FROM users u LEFT JOIN transactions t ON ... LEFT JOIN group_members gm ON ... GROUP BY u.id` |
| 预期效果 | 1+N 次查询 → 1 次查询;列表加载从 O(n) 降至 O(1) |
### Perf-6前端数据缓存
| 项目 | 说明 |
|------|------|
| 当前问题 | 分类、标签等低频变更数据每次 `onShow` 都重新请求 |
| 优化策略 | 增加 `lastFetchTime` 标记5 分钟内 `onShow` 跳过请求 |
| 实现方式 | Store 中增加 `lastFetchTime: Ref<number>``fetch()` 前判断 `Date.now() - lastFetchTime.value < 5 * 60 * 1000` 则跳过 |
| 预期效果 | 页面切换时减少 60-80% 的冗余请求 |
### Perf-7未读计数缓存
| 项目 | 说明 |
|------|------|
| 当前问题 | 每次进入"我的"页面或 `onShow` 都请求未读通知计数 |
| 优化策略 | 计数结果缓存 30 秒;读取通知后主动清零缓存 |
| 实现方式 | `unreadCount` 请求后记录 `lastFetchTime`30s 内直接返回缓存值;`markAllRead()` 后 `unreadCount = 0` + 清缓存 |
| 预期效果 | 频繁切换页面时减少通知计数请求 80%+ |
### Perf-8Logs 流式读取P2
| 项目 | 说明 |
|------|------|
| 当前问题 | 管理后台查看日志时全量加载到内存 |
| 优化策略 | 使用 Node.js stream 分批返回 |
| 实现方式 | `fs.createReadStream(logPath).pipe(res)` 或数据库 `queryStream()` |
| 预期效果 | 内存占用从 O(n) 降至 O(buffer_size) |
### Perf-9ChartWrapper 延迟渲染P2
| 项目 | 说明 |
|------|------|
| 当前问题 | 统计页所有图表同时渲染,低端设备卡顿 |
| 优化策略 | 使用 IntersectionObserver图表进入可视区域后才渲染 |
| 实现方式 | ChartWrapper 组件内监听可视状态,不可见时显示占位(固定高度 div |
| 预期效果 | 首屏渲染时间减少 30-50% |
---
## 七、测试体系建设计划
> 目标:从零覆盖到核心路径有保障,分 4 阶段逐步建设
### 阶段一后端路由集成测试P1预计 2 周)
**范围**:核心 CRUD 路由的集成测试
**技术栈**Jest + supertest + 测试数据库
**覆盖清单**
| 模块 | 测试用例 | 优先级 |
|------|----------|--------|
| auth | 登录成功/失败、Token 生成与验证、无效 Token 拒绝 | P0 |
| transaction | CRUD、group_id 过滤、分页、金额精度 | P0 |
| category | CRUD、默认/自定义分类隔离、排序、迁移 | P1 |
| budget | CRUD、月度预算、群组视图 | P1 |
| stats | overview/category/trend 查询、period 参数校验 | P2 |
**实现步骤**
1. 安装依赖:`npm i -D jest ts-jest supertest @types/supertest`
2. 创建测试数据库配置:`.env.test` 指向独立测试库
3. 编写 `tests/setup.ts`:每个测试套件前清空测试库 + seed
4. 编写路由测试:使用 `supertest(app)` 发送请求,断言状态码 + 返回格式
5. 配置 `npm test` 命令
**验收标准**:核心路由测试覆盖率 ≥ 80%`npm test` 全绿。
### 阶段二:前端 Store 单元测试P1预计 1.5 周)
**范围**Pinia Store 的核心逻辑测试
**技术栈**Vitest + @pinia/testing
**覆盖清单**
| Store | 测试用例 | 优先级 |
|-------|----------|--------|
| transaction | fetchList/fetchDetail/create/update/delete、乐观更新、删除撤销 | P0 |
| category | fetchAll/create/update/sort/乐观更新 | P1 |
| budget | fetch/set/群组视图 | P1 |
| group | fetchGroups/switchToPersonal/leaveGroupBUG-03 回归) | P0 |
| user | login/logout/token 管理 | P1 |
**实现步骤**
1. 安装依赖:`npm i -D vitest @pinia/testing @vue/test-utils`
2. 创建 `vitest.config.ts`
3. Mock API 调用:使用 `vi.mock('@/api/*')`
4. 编写 Store 测试:使用 `createTestingPinia()`
5. 配置 `npm run test:unit` 命令
**验收标准**Store 核心方法测试覆盖率 ≥ 70%。
### 阶段三API 契约测试P2预计 1 周)
**范围**:确保前后端 API 接口格式一致
**技术栈**Jest + JSON Schema 验证
**实现方式**
1. 定义 API 响应 Schema`tests/schemas/`
```typescript
// transaction-response.schema.ts
export const transactionListSchema = {
type: 'object',
required: ['code', 'data'],
properties: {
code: { const: 0 },
data: {
type: 'object',
required: ['list', 'total'],
properties: {
list: { type: 'array' },
total: { type: 'number' }
}
}
}
}
```
2. 集成测试中同时验证响应格式符合 Schema
3. 前端 `request.ts` 添加开发模式响应校验
**验收标准**:所有已定义 Schema 的接口响应 100% 符合契约。
### 阶段四工具函数单元测试P2预计 1 周)
**范围**:前端 `utils/` 和后端 `utils/` 的纯函数测试
**覆盖清单**
| 模块 | 测试用例 |
|------|----------|
| `format.ts` | `formatAmount`、`formatDate`(含跨年 BUG-02 回归)、`formatAmountRaw` |
| `request.ts` | 401 重试队列、超时处理、请求参数格式 |
| `app-ready.ts` | waitForReady 正常/超时场景 |
| `backup.ts`(后端) | 备份文件生成、签名校验 |
| `date.ts`(后端) | `getCurrentMonth`、`getMonthRange` |
**验收标准**:工具函数测试覆盖率 ≥ 90%。
---
## 八、待确认问题
| # | 问题 | 影响范围 | 建议方案 | 风险 |
|---|------|----------|----------|------|
| Q1 | Refresh Token 存储方式:数据库 vs Redis | S5 | 当前技术栈无 Redis建议存 MySQL `refresh_tokens` 表;如果后续并发量增长,迁移到 Redis | MySQL 写入频率低,短中期可行 |
| Q2 | 软删除的级联范围:是否需要同时软删除该用户的所有交易记录? | S6 | 建议仅软删除用户账号(`deleted_at`),交易记录保留但通过 `deleted_at IS NULL` 过滤;历史数据完整性更重要 | 保留交易可能影响群组统计,需在群组视图中标注"已注销用户" |
| Q3 | 删除撤销的批量操作上限:最多支持同时撤销几条? | UX-1 | 建议上限 5 条Snackbar 最多显示"已删除 5 条记录");超过 5 条时直接删除不走撤销流程 | 5 条覆盖绝大多数场景,过多会增加本地状态管理复杂度 |
| Q4 | 离线记账的冲突策略:如果离线期间同一条记录被其他设备修改了怎么办? | UX-6 | MVP 阶段仅支持离线新增(不涉及修改/删除),新增记录使用服务端生成的 ID不存在主键冲突 | 如需支持离线编辑,需引入版本号或时间戳对比机制 |
| Q5 | 财务报告推送的微信订阅消息模板审核:微信对金融类模板审核严格,是否需要用户额外授权? | F-1 | 建议先实现站内通知(现有通知系统),微信订阅消息作为增强项;需提前申请微信模板消息审核 | 微信审核可能被拒,需准备备选方案(如短信或仅站内推送) |
| Q6 | 预算预警的检查时机:仅在记账后检查 vs 定时全量扫描? | F-2 | 建议双重策略:记账后实时检查(精准、低开销)+ 每日凌晨全量扫描(覆盖非记账场景如周期记账自动生成) | 仅实时检查可能遗漏周期记账触发的超支 |
| Q7 | 乐观更新的回滚粒度:分类排序失败时,是回滚全部排序还是仅回滚失败项? | Perf-1 | 建议回滚全部:排序是全量操作(传递完整 ID 数组),部分回滚语义不清晰;失败后恢复排序前快照 + toast | 全量回滚用户感知更一致,但可能丢失用户已做的其他排序操作 |
| Q8 | 测试环境数据隔离:集成测试使用独立数据库还是 Docker 容器? | T-1 | 建议独立测试数据库(`.env.test`),不用 Docker减少 CI 复杂度CI 环境中用 GitHub Actions service container 启动 MySQL | 本地开发需额外配置测试数据库,但比 Docker 方案简单 |
---
## 九、迭代排期建议
| 阶段 | 时间 | 内容 | 交付物 |
|------|------|------|--------|
| **Phase 0** | 第 1 周 | P0 安全修复S1/S2/S3 | 凭据轮换完成、SQL 参数化、TOKEN_SECRET 强制 |
| **Phase 1** | 第 2-3 周 | P1 安全S4-S7+ P1 代码质量C1/C2/C4/C5/C7 | 双 Token 机制、软删除、代码去重 |
| **Phase 2** | 第 4-5 周 | P1 性能Perf-1~7+ P1 UXUX-1~4 | 乐观更新、聚合接口、删除撤销、群组只读 |
| **Phase 3** | 第 6-7 周 | P1 测试T-1+ P1 新功能F-1/F-2 | 集成测试覆盖、财务报告推送、预算预警 |
| **Phase 4** | 第 8-10 周 | P2 需求C3/C6/C8/C9 + Perf-8~9 + UX-5~7 + T-2~3 + F-3~5 | 离线支持、AA 分账、智能记账 |
| **Phase 5** | 远期 | P3 需求C10 + Perf-10 | 路由中间件统一、数据可视化增强 |
---
*文档版本: v2.0 | 最后更新: 2026-06-10*

View File

@@ -0,0 +1,92 @@
sequenceDiagram
participant C as Client (request.ts)
participant S as Server (auth.ts)
participant M as Server (auth middleware)
participant DB as MySQL (refresh_tokens)
Note over C,S: 1. 正常登录
C->>S: POST /auth/login { code }
S->>DB: INSERT refresh_tokens (user_id, token_hash, expires_at)
S-->>C: { accessToken(2h), refreshToken(30d), userId }
Note over C,S: 2. API 调用Access Token 有效)
C->>M: GET /api/xxx Authorization: Bearer accessToken
M->>M: 验证 HMAC 签名 + 有效期
M-->>C: 200 OK { code: 0, data }
Note over C,S: 3. Access Token 过期
C->>M: GET /api/xxx Authorization: Bearer expiredToken
M->>M: 签名有效但已过期
M-->>C: 401 { code: 40101, message: token已过期 }
Note over C,S: 4. 自动刷新
C->>S: POST /auth/refresh { refreshToken }
S->>DB: SELECT WHERE token_hash=SHA256(refreshToken) AND revoked_at IS NULL AND expires_at>NOW()
DB-->>S: 找到记录
S->>DB: UPDATE refresh_tokens SET revoked_at=NOW() WHERE id=oldId
S->>DB: INSERT refresh_tokens (新 token_hash)
S-->>C: { accessToken, refreshToken, expiresIn }
C->>C: 存储新 Token重试原请求
Note over C,S: 5. Refresh Token 也过期
C->>S: POST /auth/refresh { expiredRefreshToken }
S->>DB: SELECT WHERE token_hash=SHA256(...) AND expires_at>NOW()
DB-->>S: 未找到记录
S-->>C: 401 { code: 40101, message: refresh token已过期 }
C->>C: 清除所有 Token跳转登录页
Note over C,S: 6. 删除撤销流程
participant U as User
participant P as BillsPage
participant ST as TransactionStore
participant SN as Snackbar
participant API as Server API
U->>P: 左滑删除记录
P->>ST: pendingDelete(id, item, index)
ST->>ST: 从 transactions 列表移除
ST->>SN: 显示 Snackbar 已删除1条记录 撤销
alt 3秒内点击撤销
U->>SN: 点击 撤销
SN->>ST: cancelDelete(id)
ST->>ST: 恢复到列表原位置
else 3秒超时
SN->>ST: confirmDelete(id)
ST->>API: DELETE /api/transactions/id
alt 删除成功
API-->>ST: 200 OK
ST->>ST: 清除 pendingDelete 记录
else 删除失败
API-->>ST: 500 Error
ST->>ST: 恢复到列表原位置
ST->>U: toast 删除失败
end
end
Note over C,S: 7. 预算预警流程
participant T as Transaction Route
participant BA as BudgetAlert Service
participant NS as Notification System
participant WX as WeChat API
C->>T: POST /api/transactions { amount, type, date }
T->>DB: INSERT INTO transactions
T->>BA: checkBudgetAlert(userId, month)
BA->>DB: SELECT budget FROM budgets WHERE user_id=? AND month=?
BA->>DB: SELECT SUM(amount) FROM transactions WHERE user_id=? AND type=expense
alt 支出/预算 >= 80% 且未推送过
BA->>DB: INSERT budget_alerts (level=80)
BA->>NS: 创建站内通知
else 支出/预算 >= 100%
BA->>DB: INSERT budget_alerts (level=100)
BA->>NS: 创建站内通知
BA->>WX: 发送微信订阅消息
else 支出/预算 >= 120%
BA->>DB: INSERT budget_alerts (level=120)
BA->>NS: 创建紧急站内通知 (is_urgent=true)
BA->>WX: 发送微信订阅消息
end
T-->>C: { code: 0, data: { id } }

View File

@@ -0,0 +1,320 @@
# S1 全面扫描 — 实施计划
> **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:** 对「小菜记账」全项目进行三维度(缺陷/UI/功能)全面扫描,产出结构化问题清单
**Architecture:** 5 个并行扫描组 + 1 个汇总任务。每组用 Explore agent 深度检查文件,从缺陷/UI/功能三个维度输出 P0-P3 优先级发现。
**Tech Stack:** TypeScript, Vue 3, Uni-app, Express, MySQL
---
## 文件结构
```
client/src/ server/src/
├── pages/ ├── index.ts
│ ├── index/index.vue ├── middleware/
│ ├── add/index.vue │ ├── auth.ts
│ ├── bills/ │ ├── logger.ts
│ │ ├── index.vue │ └── requireAdmin.ts
│ │ └── filter-panel.vue ├── routes/
│ ├── stats/index.vue │ ├── auth.ts
│ ├── profile/index.vue │ ├── transaction.ts
│ ├── profile-edit/index.vue │ ├── category.ts
│ ├── budget/index.vue │ ├── budget.ts
│ ├── category-manage/index.vue │ ├── stats.ts
│ ├── group-manage/index.vue │ ├── user.ts
│ ├── notifications/index.vue │ ├── group.ts
│ ├── feedback/index.vue │ ├── notification.ts
│ ├── privacy/index.vue │ ├── filter.ts
│ └── admin/ │ ├── feedback.ts
│ ├── index.vue │ ├── config.ts
│ ├── users.vue │ ├── admin.ts
│ ├── notifications.vue │ ├── track.ts
│ ├── feedback.vue │ ├── logs.ts
│ ├── config.vue │ └── backup.ts
│ ├── logs.vue ├── utils/
│ └── track.vue │ ├── backup.ts
├── components/ │ └── date.ts
│ ├── Icon/Icon.vue └── db/
│ ├── Numpad/Numpad.vue ├── connection.ts
│ ├── TransactionItem/...vue └── init.ts
│ ├── Skeleton/Skeleton.vue
│ ├── SaveSuccess/...vue
│ ├── BudgetBar/BudgetBar.vue
│ ├── CategoryIcon/...vue
│ └── ChartWrapper/...vue
├── stores/
│ ├── transaction.ts
│ ├── category.ts
│ ├── budget.ts
│ ├── stats.ts
│ ├── user.ts
│ ├── group.ts
│ ├── filter.ts
│ ├── notification.ts
│ └── config.ts
├── api/
│ ├── auth.ts, transaction.ts, category.ts
│ ├── budget.ts, stats.ts, user.ts
│ ├── group.ts, notification.ts, filter.ts
│ ├── feedback.ts, config.ts, admin.ts, logs.ts
│ └── index.ts
├── utils/
│ ├── request.ts, format.ts, system.ts
│ ├── app-ready.ts, tracker.ts
└── config.ts
```
---
## 扫描检查清单(每个文件按此标准检查)
每个文件从以下三个维度检查:
### 🔧 缺陷维度
- [ ] API 参数类型:`request()` 调用是否使用 `{ url, method?, data? }` 格式
- [ ] null/undefined 处理:是否可能传递 undefined 到查询参数
- [ ] 边界条件空数组、空字符串、0 值的处理
- [ ] 竞态条件:快速切换页面时请求是否可能覆盖
- [ ] 小程序适配:胶囊按钮避让 (capsuleRight)、scroll-view 高度
- [ ] 数据加载onMounted 是否先 await waitForReady()
- [ ] catch 块:是否有用户提示而非静默失败
- [ ] 后端响应格式:是否返回 `{ code: 0, data }``{ code: xxx, message }`
### 🎨 UI 维度
- [ ] 加载态:是否有 loading/skeleton避免白屏
- [ ] 空状态:无数据时是否有友好的空状态提示
- [ ] 错误态:是否有错误提示机制
- [ ] 触摸反馈:可点击元素是否有 `:active` 样式
- [ ] 设计一致性:颜色/圆角/阴影使用 SCSS 变量,不硬编码
- [ ] 触摸目标:交互元素 ≥ 44×44px
- [ ] emoji禁止使用 emoji必须用 Icon 组件
- [ ] 动画:是否尊重 prefers-reduced-motion
- [ ] 文字对比度:≥ 4.5:1 (WCAG AA)
### 🚀 功能维度
- [ ] 分页:是否正确分页,是否有「没有更多了」
- [ ] 筛选:筛选条件变更时是否重置页码
- [ ] 输入校验:前端是否有校验,后端是否有校验
- [ ] 错误码规范0/40001/40300/40400/50000
- [ ] 数据流API → store → 组件是否清晰
### 优先级定义
| 级别 | 含义 | 示例 |
|------|------|------|
| P0 | 数据/安全/崩溃 | 金额错误、SQL 注入、白屏 |
| P1 | 功能不可用 | 按钮无响应、数据不刷新、接口报错 |
| P2 | 体验不佳 | 空状态缺失、加载闪烁 |
| P3 | 建议改进 | 代码整洁、命名、注释 |
---
### Task 1: 扫描组 1 — 核心页面
**Files (13):**
- `client/src/pages/index/index.vue`
- `client/src/pages/add/index.vue`
- `client/src/pages/bills/index.vue`
- `client/src/pages/bills/filter-panel.vue`
- `client/src/pages/stats/index.vue`
- `client/src/stores/transaction.ts`
- `client/src/stores/category.ts`
- `client/src/stores/budget.ts`
- `client/src/stores/stats.ts`
- `client/src/api/transaction.ts`
- `client/src/api/category.ts`
- `client/src/api/budget.ts`
- `client/src/api/stats.ts`
- [ ] **Step 1: 并行读取所有文件**
使用 Explore agent读取上述全部 13 个文件,对每个文件按「扫描检查清单」逐项检查。
- [ ] **Step 2: 输出结构化发现清单**
输出格式:
```markdown
## 组 1: 核心页面 — 扫描结果
### 🔧 缺陷
| 优先级 | 文件:行号 | 问题 | 影响 |
|--------|----------|------|------|
| P1 | pages/bills/index.vue:42 | onMounted 缺少 waitForReady | 登录未完成就发请求,可能 401 |
### 🎨 UI 问题
| 优先级 | 文件:行号 | 问题 | 影响 |
|--------|----------|------|------|
| P2 | pages/index/index.vue:xxx | 无空状态提示 | 新用户看到空白页 |
### 🚀 功能缺口
| 优先级 | 文件:行号 | 问题 | 建议 |
|--------|----------|------|------|
| P2 | stores/transaction.ts | 无请求去重 | 快速点击可能重复提交 |
```
---
### Task 2: 扫描组 2 — 用户 & 设置
**Files (18):**
- `client/src/pages/profile/index.vue`
- `client/src/pages/profile-edit/index.vue`
- `client/src/pages/budget/index.vue`
- `client/src/pages/category-manage/index.vue`
- `client/src/pages/group-manage/index.vue`
- `client/src/pages/notifications/index.vue`
- `client/src/pages/feedback/index.vue`
- `client/src/pages/privacy/index.vue`
- `client/src/stores/user.ts`
- `client/src/stores/group.ts`
- `client/src/stores/filter.ts`
- `client/src/stores/notification.ts`
- `client/src/stores/config.ts`
- `client/src/api/user.ts`
- `client/src/api/group.ts`
- `client/src/api/notification.ts`
- `client/src/api/feedback.ts`
- `client/src/api/filter.ts`
- [ ] **Step 1: 并行读取所有文件**
使用 Explore agent读取上述全部 18 个文件,对每个文件按「扫描检查清单」逐项检查。
- [ ] **Step 2: 输出结构化发现清单**
格式同 Task 1。
---
### Task 3: 扫描组 3 — 管理后台
**Files (10):**
- `client/src/pages/admin/index.vue`
- `client/src/pages/admin/users.vue`
- `client/src/pages/admin/notifications.vue`
- `client/src/pages/admin/feedback.vue`
- `client/src/pages/admin/config.vue`
- `client/src/pages/admin/logs.vue`
- `client/src/pages/admin/track.vue`
- `client/src/api/admin.ts`
- `client/src/api/config.ts`
- `client/src/api/logs.ts`
- [ ] **Step 1: 并行读取所有文件**
使用 Explore agent读取上述全部 10 个文件,对每个文件按「扫描检查清单」逐项检查。
- [ ] **Step 2: 输出结构化发现清单**
格式同 Task 1。
---
### Task 4: 扫描组 4 — 后端路由 & 基础设施
**Files (23):**
- `server/src/index.ts`
- `server/src/middleware/auth.ts`
- `server/src/middleware/logger.ts`
- `server/src/middleware/requireAdmin.ts`
- `server/src/routes/auth.ts`
- `server/src/routes/transaction.ts`
- `server/src/routes/category.ts`
- `server/src/routes/budget.ts`
- `server/src/routes/stats.ts`
- `server/src/routes/user.ts`
- `server/src/routes/group.ts`
- `server/src/routes/notification.ts`
- `server/src/routes/filter.ts`
- `server/src/routes/feedback.ts`
- `server/src/routes/config.ts`
- `server/src/routes/admin.ts`
- `server/src/routes/track.ts`
- `server/src/routes/logs.ts`
- `server/src/routes/backup.ts`
- `server/src/utils/backup.ts`
- `server/src/utils/date.ts`
- `server/src/db/connection.ts`
- `server/src/db/init.ts`
- [ ] **Step 1: 并行读取所有文件**
使用 Explore agent读取上述全部 23 个文件,对每个文件按以下关注点检查:
**后端专项检查:**
- SQL 注入风险(参数化查询)
- 输入校验(类型、范围)
- 响应格式统一(`{ code, data/message }`
- 错误码规范
- LIMIT/OFFSET 参数类型(整数)
- 认证/授权逻辑完整性
- 日志记录完整性
- [ ] **Step 2: 输出结构化发现清单**
格式同 Task 1。
---
### Task 5: 扫描组 5 — 公共组件 & 基础设施
**Files (12):**
- `client/src/components/Icon/Icon.vue`
- `client/src/components/Numpad/Numpad.vue`
- `client/src/components/TransactionItem/TransactionItem.vue`
- `client/src/components/Skeleton/Skeleton.vue`
- `client/src/components/SaveSuccess/SaveSuccess.vue`
- `client/src/components/BudgetBar/BudgetBar.vue`
- `client/src/components/CategoryIcon/CategoryIcon.vue`
- `client/src/components/ChartWrapper/ChartWrapper.vue`
- `client/src/utils/request.ts`
- `client/src/utils/format.ts`
- `client/src/utils/tracker.ts`
- `client/src/config.ts`
- [ ] **Step 1: 并行读取所有文件**
使用 Explore agent读取上述全部 12 个文件,对每个文件按「扫描检查清单」逐项检查。
- [ ] **Step 2: 输出结构化发现清单**
格式同 Task 1。
---
### Task 6: 汇总 & 排期
- [ ] **Step 1: 合并 5 组发现清单**
将所有扫描结果合并为一文档,按优先级分组:
```markdown
# S1 全面扫描 — 汇总报告
## P0 — 立即修复N 项)
| 组 | 文件 | 问题 |
|----|------|------|
## P1 — 高优先级N 项)
...
## P2 — 中优先级N 项)
...
## P3 — 建议改进N 项)
...
```
- [ ] **Step 2: 提交汇总报告**
```bash
git add docs/superpowers/
git commit -m "docs: S1 全面扫描汇总报告"
```

View File

@@ -0,0 +1,107 @@
# S1 全面扫描 — 汇总报告
日期: 2026-06-08
## 统计总览
| 优先级 | 数量 | 说明 |
|--------|------|------|
| P0 | 0 | 无数据丢失/安全漏洞/崩溃风险 |
| P1 | 1 | 功能问题 |
| P2 | 4 | 体验/规范问题 |
| P3 | 7 | 建议改进 |
| **合计** | **12** | |
---
## P1 — 高优先级1 项)
### 1. notifications 页面使用 emoji 违反设计规范
| 文件 | 行号 | 问题 | 影响 |
|------|------|------|------|
| [notifications/index.vue](client/src/pages/notifications/index.vue) | 43 | `<text class="pin-badge">📌</text>` 使用 emoji 作为图钉图标 | 设计规范明确禁止 emoji必须使用 Icon 组件 |
**修复方向**:将 `📌` 替换为 `<Icon name="pin" ... />`
---
## P2 — 中优先级4 项)
### 2. stats 趋势条动画缺少 prefers-reduced-motion
| 文件 | 行号 | 问题 | 影响 |
|------|------|------|------|
| [stats/index.vue](client/src/pages/stats/index.vue) | 550 | `.trend-bar { transition: width 0.6s ease-out; }``prefers-reduced-motion` 处理 | 对动画敏感用户不友好 |
**修复方向**:添加 `@media (prefers-reduced-motion: reduce) { .trend-bar { transition: none; } }`
### 3. admin/logs 页面日期选择触发双重请求
| 文件 | 行号 | 问题 | 影响 |
|------|------|------|------|
| [admin/logs.vue](client/src/pages/admin/logs.vue) | 181-185, 206-209 | `selectDate()` 直接调用 `loadLogs()`,同时 `watch([filterLevel, selectedDate])` 也触发 `loadLogs()`,导致日期变更时请求发两次 | 浪费带宽,可能导致竞态 |
**修复方向**`selectDate()` 只设置 `selectedDate.value`,删除手动 `loadLogs()` 调用,由 watcher 统一触发。
### 4. bills 页面列表参数使用 any 类型
| 文件 | 行号 | 问题 | 影响 |
|------|------|------|------|
| [bills/index.vue](client/src/pages/bills/index.vue) | 109, 165 | `txList: any[]``params: any` 丢失类型安全 | 重构时易引入 bug |
**修复方向**:使用 `Transaction[]``TransactionParams` 类型。
### 5. bills 空状态缺少图标
| 文件 | 行号 | 问题 | 影响 |
|------|------|------|------|
| [bills/index.vue](client/src/pages/bills/index.vue) | 82-84 | 空状态只显示文字「暂无账单记录」,无图标 | 与其他页面空状态不一致(其他页面都有 Icon |
**修复方向**:添加 `<Icon name="edit" :size="48" color="#BFB3B3" />`
---
## P3 — 建议改进7 项)
### 6. 多处使用 `any` 类型
| 文件 | 行号 | 问题 |
|------|------|------|
| [index/index.vue](client/src/pages/index/index.vue) | 168 | `recentTx` 类型 `any[]`,应为 `Transaction[]` |
| [add/index.vue](client/src/pages/add/index.vue) | 139 | `getCurrentPages()` 结果 cast `as any` |
| [budget/index.vue](client/src/pages/budget/index.vue) | 93 | `budget` 类型 `any`,应为 `Budget \| null` |
| [profile/index.vue](client/src/pages/profile/index.vue) | 329 | `allList` 类型 `any[]`,应为 `Transaction[]` |
| [admin/users.vue](client/src/pages/admin/users.vue) | 99 | `params` 类型 `any`,应为具名类型 |
| [admin/feedback.vue](client/src/pages/admin/feedback.vue) | 105 | `list` 类型 `any[]`,应使用 Feedback 类型 |
### 7. stats 页面 loadData / loadTabData 代码重复
| 文件 | 行号 | 问题 |
|------|------|------|
| [stats/index.vue](client/src/pages/stats/index.vue) | 245-281 | `loadData``loadTabData` 有大量重复代码loadSeq、错误处理可抽取公共函数 |
---
## 总体评价
代码质量整体良好,主要体现在:
-**认证机制完善**HMAC-SHA256 token + timingSafeEqual30天过期
-**竞态保护**bills、stats、notifications 页面均使用 `loadSeq` 防止并发覆盖
-**401 队列重试**`request.ts` 自动重登录 + 队列重试,设计精良
-**统一设计系统**SCSS 变量、mixin 复用度高Claymorphism 风格统一
-**响应格式统一**:后端 API 返回 `{ code, data/message }` 标准格式
-**输入校验**:前后端均有校验(金额范围、必填字段、类型白名单)
-**参数化查询**:后端 SQL 使用 `?` 占位符,无 SQL 注入风险
-**空状态/加载态/错误态**:大部分页面三态齐全
-**小程序适配**:胶囊按钮避让 `capsuleRight` 使用正确
-**数据加载模式**`waitForReady()` + `initialLoaded` 守卫模式统一
-**动画可访问性**:大部分页面有 `prefers-reduced-motion` 处理
主要改进方向集中在:
1. 消除 emoji1 处)
2. 补全 `prefers-reduced-motion`1 处)
3. 修复重复请求1 处)
4. 统一空状态样式1 处)
5. 减少 `any` 类型使用6 处)

View File

@@ -0,0 +1,110 @@
# 小菜记账 — 质量迭代设计
日期: 2026-06-08
## 概述
对「小菜记账」进行全面质量迭代,分四个阶段推进:全面扫描 → 缺陷修复 → UI 交互优化 → 系统功能迭代。
## 总体方案:分阶段逐层推进
```
S1 全面扫描 → S2 缺陷修复 → S3 UI 优化 → S4 功能迭代
```
每阶段有独立验收标准,后续阶段基于前一阶段的稳定基线。
---
## S1: 全面扫描
### 扫描维度
对每个文件从三个维度同时检查:
#### 🔧 缺陷维度
- **类型安全**: API 参数类型错误、null/undefined 处理不当、`any` 滥用
- **边界条件**: 空数据、空字符串、0 值、数组越界
- **竞态条件**: 快速切换页面时未取消的请求
- **小程序适配**: 胶囊按钮避让缺失、scroll-view 高度计算错误
- **内存泄漏**: 未清理的 timer、watcher、事件监听
- **响应格式**: 后端路由是否统一返回 `{ code, data/message }`
- **错误码规范**: 是否符合 0=成功, 40001=参数错误, 40300=权限, 40400=不存在, 50000=服务器错误
#### 🎨 UI 维度
- **加载态**: 是否有 loading/skeleton避免白屏
- **空状态**: 是否有友好的空状态提示(图标+文案)
- **错误态**: 网络错误/超时是否有用户提示
- **触摸反馈**: 按钮/列表项是否有点击态(`:active` 样式)
- **动画过渡**: 是否尊重 `prefers-reduced-motion`
- **设计一致性**: 颜色、圆角、阴影是否使用 SCSS 变量,而非硬编码
- **触摸目标**: 是否 ≥ 44×44px小程序最小触摸区域
- **emoji 检查**: 是否混用 emoji 代替 Icon 组件(设计规范禁止 emoji
#### 🚀 功能维度
- **数据完整性**: 分页是否正确、筛选是否生效
- **校验完整性**: 前端+后端是否都有输入校验
- **错误处理**: catch 块是否有用户提示(而非静默失败)
- **数据流**: API → store → 组件数据流是否清晰,避免 prop drilling
### 扫描分组
| 扫描组 | 覆盖范围 | 文件数(约) |
|--------|---------|------------|
| 组 1: 核心页面 | 首页、记账、账单、统计 + 对应 store + API | ~10 |
| 组 2: 用户 & 设置 | 个人、编辑资料、预算、分类管理、群组、通知、反馈、隐私 | ~12 |
| 组 3: 管理后台 | 仪表盘、用户管理、公告、反馈、配置、日志、埋点 | ~10 |
| 组 4: 后端路由 | 15 个路由 + 中间件 + 工具函数 | ~20 |
| 组 5: 公共组件 & 基础设施 | Icon、Numpad、TransactionItem、Skeleton、request、tracker 等 | ~10 |
### 产出物
每个扫描组输出结构化发现清单,每条发现包含:
- **优先级**: P0(立即)/P1(高)/P2(中)/P3(低)
- **文件**: 精确到行号
- **问题描述**: 清晰说明缺陷/问题
- **影响**: 用户可见影响
所有发现汇总到统一清单,供审核和排期。
### 优先级定义
| 级别 | 含义 | 示例 |
|------|------|------|
| P0 | 数据丢失/安全/崩溃 | 金额计算错误、SQL 注入、白屏 |
| P1 | 功能不可用/严重体验问题 | 按钮无响应、数据不刷新、接口报错 |
| P2 | 体验不佳/边界情况 | 空状态缺失、加载闪烁、动画卡顿 |
| P3 | 建议改进 | 代码整洁、变量命名、注释补充 |
---
## S2: 缺陷修复(扫描后细化)
- 按 P0 → P1 → P2 顺序修复
- 每个修复前先写测试用例复现
- 修复后验证已有测试通过
---
## S3: UI 交互优化(扫描后细化)
- 统一加载/空/错误状态组件
- 统一触摸反馈模式
- 动画一致性检查
- 无障碍改进
---
## S4: 系统功能迭代(扫描后细化)
- 基于扫描发现的功能缺口
- 新功能需写 spec → plan → implement
---
## 不涉及的范围
- 不修改 `dist/` 目录
- 不修改 lock 文件
- 不做无关重构
- 不做数据库结构大改(如需迁移需单独评审)

View File

@@ -1,24 +1,21 @@
# Server # Server
PORT=3000 PORT=3000
# Security
TOKEN_SECRET=your_random_secret_here
# CORS (comma-separated origins, empty = allow all)
CORS_ORIGINS=
# MySQL # MySQL
DB_HOST=your_mysql_host_here DB_HOST=your_db_host
DB_USER=your_mysql_user_here DB_USER=your_db_user
DB_PASSWORD=your_mysql_password_here DB_PASSWORD=your_db_password
DB_NAME=your_mysql_name_here DB_NAME=xiaocai
# Uploads # Uploads
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
# Backup # Backup
BACKUP_DIR=/var/backups/xiaocai BACKUP_DIR=./backups
# Token (REQUIRED - server will not start with default value)
TOKEN_SECRET=change-me-to-a-secure-random-string
# WeChat Mini Program # WeChat Mini Program
WX_APPID=your_wx_appid_here WX_APPID=your_wx_appid
WX_SECRET=your_wx_secret_here WX_SECRET=your_wx_secret

7
server/jest.config.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
}

3948
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc && shx cp src/db/*.sql dist/db/", "build": "tsc && shx cp src/db/*.sql dist/db/",
"start": "node dist/index.js", "start": "node dist/index.js",
"db:init": "tsx src/db/init.ts" "db:init": "tsx src/db/init.ts",
"test": "jest"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -14,14 +15,19 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"multer": "^2.1.1", "multer": "^2.1.1",
"mysql2": "^3.9.0" "mysql2": "^3.9.0",
"node-cron": "^3.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/multer": "^2.1.0", "@types/multer": "^2.1.0",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/supertest": "^6.0.3",
"jest": "^29.7.0",
"shx": "^0.4.0", "shx": "^0.4.0",
"supertest": "^6.3.4",
"ts-jest": "^29.4.11",
"tsx": "^4.7.0", "tsx": "^4.7.0",
"typescript": "^5.3.0" "typescript": "^5.3.0"
} }

View File

@@ -0,0 +1,22 @@
/**
* Token 安全配置 — 集中管理 TOKEN_SECRET 和启动校验
*
* 所有需要 TOKEN_SECRET 的模块统一从此处导入,
* 避免各文件内联定义导致的不一致风险。
*/
/** 从环境变量读取 TOKEN_SECRET无默认值启动时强制校验 */
export const TOKEN_SECRET = process.env.TOKEN_SECRET || ''
/** 启动时检查 TOKEN_SECRET 安全性,使用默认值则拒绝启动 */
export function checkTokenSecret(): void {
const defaults = [
'xiaocai-token-secret-change-in-production',
'change-me-to-a-secure-random-string',
'',
]
if (defaults.includes(TOKEN_SECRET) || TOKEN_SECRET.length < 32) {
console.error('[Security] FATAL: TOKEN_SECRET is missing, using a default, or shorter than 32 chars. Set a secure TOKEN_SECRET (min 32 chars) in .env before starting the server.')
process.exit(1)
}
}

View File

@@ -1,10 +1,18 @@
import mysql from 'mysql2/promise' import mysql from 'mysql2/promise'
// 必需环境变量校验
const REQUIRED_ENV = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']
const missing = REQUIRED_ENV.filter(key => !process.env[key])
if (missing.length > 0) {
console.error(`[DB] FATAL: Missing required environment variables: ${missing.join(', ')}`)
process.exit(1)
}
const pool = mysql.createPool({ const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST!,
user: process.env.DB_USER || 'xiaocai', user: process.env.DB_USER!,
password: process.env.DB_PASSWORD || 'xiaocai123', password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME || 'xiaocai', database: process.env.DB_NAME!,
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,
queueLimit: 0, queueLimit: 0,
@@ -14,4 +22,9 @@ const pool = mysql.createPool({
console.error('[DB] Pool error:', err?.message || err) console.error('[DB] Pool error:', err?.message || err)
}) })
/** 获取单个连接(用于事务) */
export async function getConnection() {
return pool.getConnection()
}
export default pool export default pool

View File

@@ -243,16 +243,116 @@ async function runMigrations(conn: mysql.Connection) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`) `)
} }
// 周期账单模板表
const hasRecurring = await tableExists(conn, 'recurring_templates')
if (!hasRecurring) {
console.log('[DB] Migrating: creating recurring_templates table')
await conn.query(`
CREATE TABLE IF NOT EXISTS recurring_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount INT NOT NULL COMMENT '单位:分',
type ENUM('expense', 'income') NOT NULL,
category_id INT NOT NULL,
note VARCHAR(200) DEFAULT '',
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
next_date DATE NOT NULL COMMENT '下次生成日期',
end_date DATE DEFAULT NULL COMMENT '截止日期NULL=永久',
is_active TINYINT(1) DEFAULT 1,
group_id INT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_next_date (next_date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// transactions 表添加 recurring_id
const hasRecurringId = await columnExists(conn, 'transactions', 'recurring_id')
if (!hasRecurringId) {
console.log('[DB] Migrating: adding recurring_id to transactions')
await conn.query("ALTER TABLE transactions ADD COLUMN recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID' AFTER group_id")
}
// 标签表
const hasTags = await tableExists(conn, 'tags')
if (!hasTags) {
console.log('[DB] Migrating: creating tags table')
await conn.query(`
CREATE TABLE IF NOT EXISTS tags (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(20) NOT NULL COMMENT '标签名称',
color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_name (user_id, name),
INDEX idx_user (user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// 交易-标签关联表
const hasTransactionTags = await tableExists(conn, 'transaction_tags')
if (!hasTransactionTags) {
console.log('[DB] Migrating: creating transaction_tags table')
await conn.query(`
CREATE TABLE IF NOT EXISTS transaction_tags (
transaction_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (transaction_id, tag_id),
INDEX idx_tag (tag_id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// users 表添加软删除字段
const hasDeletedAt = await columnExists(conn, 'users', 'deleted_at')
if (!hasDeletedAt) {
console.log('[DB] Migrating: adding deleted_at to users')
await conn.query("ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间NULL=正常)'")
await conn.query('ALTER TABLE users ADD INDEX idx_users_deleted (deleted_at)')
console.log('[DB] users.deleted_at added')
}
// Refresh Token 表
const hasRefreshTokens = await tableExists(conn, 'refresh_tokens')
if (!hasRefreshTokens) {
console.log('[DB] Migrating: creating refresh_tokens table')
await conn.query(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
expires_at DATETIME NOT NULL COMMENT '过期时间',
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间NULL=有效)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_hash (token_hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
console.log('[DB] refresh_tokens table created')
}
} }
export async function initDatabase() { export async function initDatabase() {
const dbName = process.env.DB_NAME || 'xiaocai' const dbName = process.env.DB_NAME!
const dbHost = process.env.DB_HOST!
const dbUser = process.env.DB_USER!
const dbPassword = process.env.DB_PASSWORD!
// 先用不指定数据库的连接,确保数据库存在 // 先用不指定数据库的连接,确保数据库存在
const bootstrap = await mysql.createConnection({ const bootstrap = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost', host: dbHost,
user: process.env.DB_USER || 'xiaocai', user: dbUser,
password: process.env.DB_PASSWORD || 'xiaocai123', password: dbPassword,
multipleStatements: true, multipleStatements: true,
}) })
@@ -261,9 +361,9 @@ export async function initDatabase() {
// 用独立连接执行建表和填充(需要 multipleStatements // 用独立连接执行建表和填充(需要 multipleStatements
const initConn = await mysql.createConnection({ const initConn = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost', host: dbHost,
user: process.env.DB_USER || 'xiaocai', user: dbUser,
password: process.env.DB_PASSWORD || 'xiaocai123', password: dbPassword,
database: dbName, database: dbName,
multipleStatements: true, multipleStatements: true,
}) })

View File

@@ -14,3 +14,22 @@ ALTER TABLE budgets ADD FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CAS
-- categories 表确认 user_id 存在(旧版可能也没有) -- categories 表确认 user_id 存在(旧版可能也没有)
ALTER TABLE categories ADD COLUMN IF NOT EXISTS user_id INT DEFAULT 0 COMMENT '0=默认分类, 其他=用户自定义' AFTER id; ALTER TABLE categories ADD COLUMN IF NOT EXISTS user_id INT DEFAULT 0 COMMENT '0=默认分类, 其他=用户自定义' AFTER id;
-- v2 迭代迁移
-- users 表:新增软删除字段
ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间NULL=正常)';
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
-- 新增 refresh_tokens 表
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
expires_at DATETIME NOT NULL COMMENT '过期时间',
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间NULL=有效)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_hash (token_hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -6,8 +6,10 @@ CREATE TABLE IF NOT EXISTS users (
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL', avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名', slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名',
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色', role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间NULL=正常)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_users_deleted (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS categories ( CREATE TABLE IF NOT EXISTS categories (
@@ -33,6 +35,7 @@ CREATE TABLE IF NOT EXISTS transactions (
note VARCHAR(200), note VARCHAR(200),
date DATE NOT NULL, date DATE NOT NULL,
group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)', group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)',
recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_date (user_id, date), INDEX idx_user_date (user_id, date),
@@ -149,3 +152,55 @@ CREATE TABLE IF NOT EXISTS track_events (
INDEX idx_created (created_at), INDEX idx_created (created_at),
INDEX idx_user (user_id) INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS recurring_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount INT NOT NULL COMMENT '单位:分',
type ENUM('expense', 'income') NOT NULL,
category_id INT NOT NULL,
note VARCHAR(200) DEFAULT '',
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
next_date DATE NOT NULL COMMENT '下次生成日期',
end_date DATE DEFAULT NULL COMMENT '截止日期NULL=永久',
is_active TINYINT(1) DEFAULT 1,
group_id INT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_next_date (next_date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS tags (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(20) NOT NULL COMMENT '标签名称',
color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_name (user_id, name),
INDEX idx_user (user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS transaction_tags (
transaction_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (transaction_id, tag_id),
INDEX idx_tag (tag_id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
expires_at DATETIME NOT NULL COMMENT '过期时间',
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间NULL=有效)',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_hash (token_hash),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -23,12 +23,14 @@ import feedbackRoutes from './routes/feedback'
import configRoutes from './routes/config' import configRoutes from './routes/config'
import trackRoutes from './routes/track' import trackRoutes from './routes/track'
import logsRoutes from './routes/logs' import logsRoutes from './routes/logs'
import recurringRoutes from './routes/recurring'
import tagRoutes from './routes/tag'
import exportRoutes from './routes/export'
import { backupDatabase } from './utils/backup' import { backupDatabase } from './utils/backup'
import { checkTokenSecret } from './config/token'
// Warn if token secret is using default fallback // 强制检查 TOKEN_SECRET 安全性
if (!process.env.TOKEN_SECRET) { checkTokenSecret()
console.warn('[Security] TOKEN_SECRET not set — using default fallback. Set TOKEN_SECRET in .env for production!')
}
const app = express() const app = express()
const PORT = process.env.PORT || 3000 const PORT = process.env.PORT || 3000
@@ -49,7 +51,7 @@ app.use(cors({
}, },
credentials: true, credentials: true,
})) }))
app.use(express.json({ limit: '10kb' })) app.use(express.json({ limit: '5mb' }))
// 请求日志 // 请求日志
app.use(requestLogger) app.use(requestLogger)
@@ -144,6 +146,28 @@ app.get('/api/notifications/image/:filename', async (req, res) => {
} }
}) })
// 健康检查端点(公开,不经过 auth 中间件)
app.get('/api/health', async (_req, res) => {
try {
await pool.query('SELECT 1')
res.json({
status: 'ok',
db: 'connected',
uptime: Math.floor(process.uptime()),
version: process.env.npm_package_version || '1.0.0',
timestamp: new Date().toISOString()
})
} catch {
res.status(503).json({
status: 'error',
db: 'disconnected',
uptime: Math.floor(process.uptime()),
version: process.env.npm_package_version || '1.0.0',
timestamp: new Date().toISOString()
})
}
})
app.use(authMiddleware) app.use(authMiddleware)
app.use('/api/auth', authLimiter, authRoutes) app.use('/api/auth', authLimiter, authRoutes)
@@ -161,15 +185,9 @@ app.use('/api/feedback', apiLimiter, feedbackRoutes)
app.use('/api/config', apiLimiter, configRoutes) app.use('/api/config', apiLimiter, configRoutes)
app.use('/api/track', apiLimiter, trackRoutes) app.use('/api/track', apiLimiter, trackRoutes)
app.use('/api/logs', apiLimiter, logsRoutes) app.use('/api/logs', apiLimiter, logsRoutes)
app.use('/api/recurring', apiLimiter, recurringRoutes)
app.get('/api/health', async (_req, res) => { app.use('/api/tags', apiLimiter, tagRoutes)
try { app.use('/api/export', apiLimiter, exportRoutes)
await pool.query('SELECT 1')
res.json({ status: 'ok', db: 'connected', time: new Date().toISOString() })
} catch {
res.status(503).json({ status: 'error', db: 'disconnected', time: new Date().toISOString() })
}
})
// 全局错误处理中间件(兜底未捕获的异常) // 全局错误处理中间件(兜底未捕获的异常)
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {

View File

@@ -1,17 +1,35 @@
import { Request, Response, NextFunction } from 'express' import { Request, Response, NextFunction } from 'express'
import { createHmac, timingSafeEqual } from 'crypto' import { createHmac, timingSafeEqual } from 'crypto'
import { TOKEN_SECRET } from '../config/token'
import pool from '../db/connection'
export interface AuthRequest extends Request { export interface AuthRequest extends Request {
userId?: number userId?: number
} }
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production' const TOKEN_EXPIRY = 2 * 60 * 60 * 1000 // 2 hours
const TOKEN_EXPIRY = 30 * 24 * 60 * 60 * 1000 // 30 days
// 不需要认证的路径 // 不需要认证的路径
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/demo-login', '/api/health'] const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/demo-login', '/api/auth/refresh', '/api/health']
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) { /** 已删除用户 ID 缓存5 分钟 TTL */
const deletedUserCache = new Map<number, number>() // userId → cachedAt
async function isUserDeleted(userId: number): Promise<boolean> {
const cached = deletedUserCache.get(userId)
if (cached && Date.now() - cached < 5 * 60 * 1000) {
return true // 仍在缓存中,视为已删除
}
const [rows] = await pool.query('SELECT deleted_at FROM users WHERE id = ?', [userId])
if (!(rows as any[]).length || (rows as any[])[0].deleted_at !== null) {
deletedUserCache.set(userId, Date.now())
return true
}
deletedUserCache.delete(userId)
return false
}
export async function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
// 公开路径跳过认证 // 公开路径跳过认证
if (PUBLIC_PATHS.some(p => req.path === p)) { if (PUBLIC_PATHS.some(p => req.path === p)) {
return next() return next()
@@ -57,6 +75,11 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti
return res.status(401).json({ code: 40100, message: 'token无效' }) return res.status(401).json({ code: 40100, message: 'token无效' })
} }
// 检查用户是否已被软删除
if (await isUserDeleted(userId)) {
return res.status(401).json({ code: 40100, message: '账号已禁用' })
}
req.userId = userId req.userId = userId
} catch { } catch {
return res.status(401).json({ code: 40100, message: 'token无效' }) return res.status(401).json({ code: 40100, message: 'token无效' })

View File

@@ -5,7 +5,7 @@ import { AuthRequest } from './auth'
/** 管理员权限检查中间件 */ /** 管理员权限检查中间件 */
export async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) { export async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
try { try {
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId]) const [rows] = await pool.query('SELECT role FROM users WHERE id = ? AND deleted_at IS NULL', [req.userId])
const user = (rows as any[])[0] const user = (rows as any[])[0]
if (!user || user.role !== 'admin') { if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '需要管理员权限' }) return res.status(403).json({ code: 40300, message: '需要管理员权限' })

View File

@@ -11,7 +11,7 @@ router.use((req, res, next) => requireAdmin(req, res, next))
/** 数据看板 */ /** 数据看板 */
router.get('/dashboard', async (_req: AuthRequest, res: Response) => { router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
try { try {
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users') const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE deleted_at IS NULL')
const [txThisMonth] = await pool.query( const [txThisMonth] = await pool.query(
`SELECT COUNT(*) as count, `SELECT COUNT(*) as count,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense, COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
@@ -71,22 +71,25 @@ router.get('/users', async (req: AuthRequest, res: Response) => {
const pSize = Math.min(100, Math.max(1, parseInt(pageSize as string) || 20)) const pSize = Math.min(100, Math.max(1, parseInt(pageSize as string) || 20))
const offset = (pNum - 1) * pSize const offset = (pNum - 1) * pSize
let where = '' let where = 'WHERE u.deleted_at IS NULL'
const params: any[] = [] const params: any[] = []
if (keyword && typeof keyword === 'string' && keyword.trim()) { if (keyword && typeof keyword === 'string' && keyword.trim()) {
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符 // 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_') const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
where = 'WHERE u.nickname LIKE ?' where += ' AND u.nickname LIKE ?'
params.push(`%${escapedKeyword}%`) params.push(`%${escapedKeyword}%`)
} }
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT u.id, u.nickname, u.avatar_url, u.role, u.created_at, `SELECT u.id, u.nickname, u.avatar_url, u.role, u.created_at,
(SELECT COUNT(*) FROM transactions WHERE user_id = u.id) as tx_count, COUNT(DISTINCT t.id) as tx_count,
(SELECT COUNT(*) FROM group_members WHERE user_id = u.id) as group_count COUNT(DISTINCT gm.group_id) as group_count
FROM users u FROM users u
LEFT JOIN transactions t ON t.user_id = u.id
LEFT JOIN group_members gm ON gm.user_id = u.id
${where} ${where}
GROUP BY u.id, u.nickname, u.avatar_url, u.role, u.created_at
ORDER BY u.created_at DESC ORDER BY u.created_at DESC
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
[...params, pSize, offset] [...params, pSize, offset]
@@ -127,7 +130,7 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
// 如果要降级管理员,检查是否至少保留一个管理员 // 如果要降级管理员,检查是否至少保留一个管理员
if (role === 'user') { if (role === 'user') {
const [adminCount] = await pool.query( const [adminCount] = await pool.query(
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?", "SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND deleted_at IS NULL AND id != ?",
[req.params.id] [req.params.id]
) )
if ((adminCount as any[])[0].count === 0) { if ((adminCount as any[])[0].count === 0) {
@@ -135,9 +138,9 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
} }
} }
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id]) const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ? AND deleted_at IS NULL', [role, req.params.id])
if ((result as any).affectedRows === 0) { if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' }) return res.status(404).json({ code: 40400, message: '用户不存在或已删除' })
} }
res.json({ code: 0 }) res.json({ code: 0 })
} catch (err) { } catch (err) {
@@ -146,7 +149,7 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
} }
}) })
/** 删除用户 */ /** 删除用户(需要二次确认用户名) */
router.delete('/users/:id', async (req: AuthRequest, res: Response) => { router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
try { try {
// 不能删除自己 // 不能删除自己
@@ -154,12 +157,27 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40001, message: '不能删除自己' }) return res.status(400).json({ code: 40001, message: '不能删除自己' })
} }
// 检查是否是管理员,如果是则确保至少保留一个 // 需要前端传 confirmName 二次确认
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.params.id]) const { confirmName } = req.body
if (!confirmName) {
return res.status(400).json({ code: 40001, message: '需要确认用户名' })
}
const [userRows] = await pool.query('SELECT nickname, role FROM users WHERE id = ? AND deleted_at IS NULL', [req.params.id])
const user = (userRows as any[])[0] const user = (userRows as any[])[0]
if (user && user.role === 'admin') { if (!user) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
}
// 确认用户名匹配
if (user.nickname !== confirmName) {
return res.status(400).json({ code: 40001, message: '确认用户名不匹配' })
}
// 如果是管理员,确保至少保留一个
if (user.role === 'admin') {
const [adminCount] = await pool.query( const [adminCount] = await pool.query(
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?", "SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND deleted_at IS NULL AND id != ?",
[req.params.id] [req.params.id]
) )
if ((adminCount as any[])[0].count === 0) { if ((adminCount as any[])[0].count === 0) {
@@ -167,10 +185,12 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
} }
} }
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]) // 软删除:设置 deleted_at
if ((result as any).affectedRows === 0) { await pool.query('UPDATE users SET deleted_at = NOW() WHERE id = ?', [req.params.id])
return res.status(404).json({ code: 40400, message: '用户不存在' })
} // 撤销该用户所有有效的 Refresh Token
await pool.query('UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = ? AND revoked_at IS NULL', [req.params.id])
res.json({ code: 0 }) res.json({ code: 0 })
} catch (err) { } catch (err) {
console.error('[Admin] delete user error:', err) console.error('[Admin] delete user error:', err)
@@ -178,4 +198,18 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
} }
}) })
/** 恢复软删除的用户 */
router.post('/users/:id/restore', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query('UPDATE users SET deleted_at = NULL WHERE id = ?', [req.params.id])
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
}
res.json({ code: 0 })
} catch (err) {
console.error('[Admin] restore user error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router export default router

View File

@@ -1,19 +1,65 @@
import { Router, Request, Response } from 'express' import { Router, Request, Response } from 'express'
import { createHmac } from 'crypto' import { createHmac, randomBytes, createHash } from 'crypto'
import pool from '../db/connection' import pool from '../db/connection'
import { TOKEN_SECRET } from '../config/token'
const router = Router() const router = Router()
const APPID = process.env.WX_APPID || '' const APPID = process.env.WX_APPID || ''
const SECRET = process.env.WX_SECRET || '' const SECRET = process.env.WX_SECRET || ''
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
/** 生成 HMAC 签名的 Access Token2h 过期) */
export function signToken(userId: number): string { export function signToken(userId: number): string {
const payload = `${userId}:${Date.now()}` const payload = `${userId}:${Date.now()}`
const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex') const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
return Buffer.from(`${payload}:${signature}`).toString('base64') return Buffer.from(`${payload}:${signature}`).toString('base64')
} }
/** 生成随机 Refresh Token64 字符十六进制) */
function generateRefreshToken(): string {
return randomBytes(32).toString('hex')
}
/** 对 token 进行 SHA-256 哈希(存储到数据库前使用) */
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
/** 登录成功后的通用逻辑:生成双 token 并存储 Refresh Token */
async function issueTokens(userId: number): Promise<{
accessToken: string
refreshToken: string
expiresIn: number
}> {
const accessToken = signToken(userId)
const refreshToken = generateRefreshToken()
const tokenHash = hashToken(refreshToken)
// 存入 refresh_tokens 表30 天过期
await pool.query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[userId, tokenHash]
)
// 检查该用户有效 refresh_tokens 数量,超过 5 个则撤销最旧的
const [tokenRows] = await pool.query(
'SELECT id FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NULL AND expires_at > NOW() ORDER BY created_at ASC',
[userId]
)
const tokens = tokenRows as any[]
if (tokens.length > 5) {
const revokeIds = tokens.slice(0, tokens.length - 5).map((t: any) => t.id)
if (revokeIds.length > 0) {
await pool.query(
`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id IN (${revokeIds.map(() => '?').join(',')})`,
revokeIds
)
}
}
return { accessToken, refreshToken, expiresIn: 7200 }
}
// H5 演示登录 — 创建/复用演示用户 // H5 演示登录 — 创建/复用演示用户
router.post('/demo-login', async (_req: Request, res: Response) => { router.post('/demo-login', async (_req: Request, res: Response) => {
try { try {
@@ -27,10 +73,14 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
// 首个用户自动设为管理员 // 首个用户自动设为管理员
await autoSetAdmin(userId) await autoSetAdmin(userId)
const token = signToken(userId) const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ? AND deleted_at IS NULL', [userId])
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
const userInfo = (userRows as any[])[0] const userInfo = (userRows as any[])[0]
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } }) if (!userInfo) {
return res.status(403).json({ code: 40300, message: '该账号已被禁用' })
}
const tokens = await issueTokens(userId)
res.json({ code: 0, data: { ...tokens, userId, nickname: userInfo.nickname || '小菜', avatar_url: userInfo.avatar_url || '', role: userInfo.role || 'user' } })
} catch (err: any) { } catch (err: any) {
console.error('[Auth] Demo login failed:', err) console.error('[Auth] Demo login failed:', err)
res.status(500).json({ code: 50000, message: '服务器错误' }) res.status(500).json({ code: 50000, message: '服务器错误' })
@@ -66,20 +116,76 @@ router.post('/login', async (req: Request, res: Response) => {
// 首个用户自动设为管理员 // 首个用户自动设为管理员
await autoSetAdmin(userId) await autoSetAdmin(userId)
// HMAC signed token with expiry // 检查用户是否已被软删除
const token = signToken(userId) const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ? AND deleted_at IS NULL', [userId])
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
const userInfo = (userRows as any[])[0] const userInfo = (userRows as any[])[0]
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } }) if (!userInfo) {
return res.status(403).json({ code: 40300, message: '该账号已被禁用' })
}
const tokens = await issueTokens(userId)
res.json({ code: 0, data: { ...tokens, userId, nickname: userInfo.nickname || '小菜', avatar_url: userInfo.avatar_url || '', role: userInfo.role || 'user' } })
} catch (err: any) { } catch (err: any) {
console.error('[Auth] Login failed:', err) console.error('[Auth] Login failed:', err)
res.status(500).json({ code: 50000, message: '服务器错误' }) res.status(500).json({ code: 50000, message: '服务器错误' })
} }
}) })
/** 刷新 Access Token */
router.post('/refresh', async (req: Request, res: Response) => {
try {
const { refreshToken } = req.body
if (!refreshToken || typeof refreshToken !== 'string') {
return res.status(400).json({ code: 40001, message: '缺少refreshToken' })
}
const tokenHash = hashToken(refreshToken)
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 加行级锁查询,防止并发重放
const [rows] = await conn.query(
'SELECT id, user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > NOW() FOR UPDATE',
[tokenHash]
)
if (!(rows as any[]).length) {
await conn.rollback()
return res.status(401).json({ code: 40100, message: 'refreshToken无效或已过期' })
}
const record = (rows as any[])[0]
// 撤销旧 token
await conn.query('UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = ?', [record.id])
// 生成新的双 token
const newAccessToken = signToken(record.user_id)
const newRefreshToken = generateRefreshToken()
const newHash = hashToken(newRefreshToken)
await conn.query(
'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))',
[record.user_id, newHash]
)
await conn.commit()
res.json({ code: 0, data: { accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: 7200 } })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Auth] Refresh token error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 首个用户自动设为管理员 */ /** 首个用户自动设为管理员 */
async function autoSetAdmin(userId: number) { async function autoSetAdmin(userId: number) {
const [adminCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']) const [adminCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE role = ? AND deleted_at IS NULL', ['admin'])
if ((adminCount as any[])[0].count === 0) { if ((adminCount as any[])[0].count === 0) {
await pool.query('UPDATE users SET role = ? WHERE id = ?', ['admin', userId]) await pool.query('UPDATE users SET role = ? WHERE id = ?', ['admin', userId])
console.log(`[Auth] User ${userId} auto-promoted to admin`) console.log(`[Auth] User ${userId} auto-promoted to admin`)

View File

@@ -2,11 +2,74 @@ import { Router, Response } from 'express'
import { AuthRequest } from '../middleware/auth' import { AuthRequest } from '../middleware/auth'
import { backupDatabase, getBackupList } from '../utils/backup' import { backupDatabase, getBackupList } from '../utils/backup'
import { requireAdmin } from '../middleware/requireAdmin' import { requireAdmin } from '../middleware/requireAdmin'
import { createHmac } from 'crypto'
import { TOKEN_SECRET } from '../config/token'
import fs from 'fs'
import path from 'path'
const router = Router() const router = Router()
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
// 所有路由都需要管理员权限 // 所有路由都需要管理员权限(除了 :id/download 使用签名校验)
router.use((req, res, next) => requireAdmin(req, res, next)) router.use((req, res, next) => {
// /:id/download 不走 requireAdmin自行校验签名
if (req.path.match(/^\/\d+\/download$/)) {
return next()
}
requireAdmin(req, res, next)
})
// 下载备份文件(签名 token 校验,不走 authMiddleware
router.get('/:id/download', async (req: AuthRequest, res: Response) => {
try {
const token = req.query.token as string
if (!token) {
return res.status(400).json({ code: 40001, message: '缺少下载令牌' })
}
// 解析 tokenbackupId:timestamp:hmac
const parts = token.split(':')
if (parts.length !== 3) {
return res.status(400).json({ code: 40001, message: '令牌格式无效' })
}
const [backupId, timestamp, hmac] = parts
// 校验 timestamp 未过期1小时内
const ts = parseInt(timestamp)
if (isNaN(ts) || Date.now() - ts > 3600000) {
return res.status(400).json({ code: 40001, message: '令牌已过期' })
}
// 校验 HMAC
const expectedHmac = createHmac('sha256', TOKEN_SECRET)
.update(`${backupId}:${timestamp}`)
.digest('hex')
if (hmac !== expectedHmac) {
return res.status(403).json({ code: 40300, message: '令牌签名无效' })
}
// 校验 backupId 存在于备份列表中
const list = getBackupList()
const backup = list.find(b => b.name === backupId)
if (!backup) {
return res.status(404).json({ code: 40400, message: '备份不存在' })
}
// 流式返回备份文件
const filepath = path.join(BACKUP_DIR, backup.name)
if (!fs.existsSync(filepath)) {
return res.status(404).json({ code: 40400, message: '备份文件不存在' })
}
res.setHeader('Content-Type', 'application/gzip')
res.setHeader('Content-Disposition', `attachment; filename=${backup.name}`)
fs.createReadStream(filepath).pipe(res)
} catch (err) {
console.error('[Backup] download error:', err)
res.status(500).json({ code: 50000, message: '下载失败' })
}
})
// 手动触发备份 // 手动触发备份
router.post('/', async (req: AuthRequest, res: Response) => { router.post('/', async (req: AuthRequest, res: Response) => {
@@ -23,11 +86,45 @@ router.post('/', async (req: AuthRequest, res: Response) => {
router.get('/', async (req: AuthRequest, res: Response) => { router.get('/', async (req: AuthRequest, res: Response) => {
try { try {
const list = getBackupList() const list = getBackupList()
res.json({ code: 0, data: list }) // 为每条记录生成下载令牌
const listWithToken = list.map(item => {
const timestamp = Date.now()
const hmac = createHmac('sha256', TOKEN_SECRET)
.update(`${item.name}:${timestamp}`)
.digest('hex')
return {
...item,
downloadToken: `${item.name}:${timestamp}:${hmac}`
}
})
res.json({ code: 0, data: listWithToken })
} catch (err) { } catch (err) {
console.error('[Backup] 获取列表失败:', err) console.error('[Backup] 获取列表失败:', err)
res.status(500).json({ code: 50000, message: '获取备份列表失败' }) res.status(500).json({ code: 50000, message: '获取备份列表失败' })
} }
}) })
// 删除备份
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const backupName = req.params.id
const list = getBackupList()
const backup = list.find(b => b.name === backupName)
if (!backup) {
return res.status(404).json({ code: 40400, message: '备份不存在' })
}
// 删除备份文件
const filepath = path.join(BACKUP_DIR, backup.name)
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath)
}
res.json({ code: 0 })
} catch (err) {
console.error('[Backup] 删除备份失败:', err)
res.status(500).json({ code: 50000, message: '删除备份失败' })
}
})
export default router export default router

View File

@@ -63,12 +63,23 @@ router.get('/', async (req: AuthRequest, res: Response) => {
router.post('/', async (req: AuthRequest, res: Response) => { router.post('/', async (req: AuthRequest, res: Response) => {
try { try {
const { amount, month } = req.body const { amount, month, group_id } = req.body
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) { if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数单位' }) return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数单位' })
} }
const m = validateMonth(month || getCurrentMonth()) const m = validateMonth(month || getCurrentMonth())
// 群组模式下验证成员身份
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: '无权操作此群组' })
}
}
await pool.query( await pool.query(
'INSERT INTO budgets (user_id, amount, month) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = ?', 'INSERT INTO budgets (user_id, amount, month) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = ?',
[req.userId, amount, m, amount] [req.userId, amount, m, amount]

View File

@@ -124,9 +124,9 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
await conn.beginTransaction() await conn.beginTransaction()
// 验证源分类存在且属于当前用户 // 验证源分类存在且属于当前用户(仅允许迁移自定义分类)
const [sourceRows] = await conn.query( const [sourceRows] = await conn.query(
'SELECT id, type FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)', 'SELECT id, type FROM categories WHERE id = ? AND is_custom = 1 AND user_id = ?',
[req.params.id, req.userId] [req.params.id, req.userId]
) )
const sourceCategory = (sourceRows as any[])[0] const sourceCategory = (sourceRows as any[])[0]

283
server/src/routes/export.ts Normal file
View File

@@ -0,0 +1,283 @@
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { Readable } from 'stream'
import { pipeline } from 'stream/promises'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { createHmac } from 'crypto'
import { TOKEN_SECRET } from '../config/token'
const router = Router()
const VALID_TYPES = ['expense', 'income', 'all']
const VALID_FORMATS = ['csv', 'json']
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
const LARGE_DATA_THRESHOLD = 10000
const EXPORT_TIMEOUT = 60000
const DOWNLOAD_TOKEN_EXPIRY = 10 * 60 * 1000 // 10 minutes
/** 生成短期下载签名 token */
function createDownloadToken(userId: number, startDate: string, endDate: string, type: string, format: string): string {
const timestamp = Date.now()
const payload = `${userId}:${startDate}:${endDate}:${type}:${format}:${timestamp}`
const hmac = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
return Buffer.from(`${payload}:${hmac}`).toString('base64')
}
/** 验证下载签名 token返回解出的参数或 null */
function verifyDownloadToken(token: string): { userId: number; startDate: string; endDate: string; type: string; format: string } | null {
try {
const decoded = Buffer.from(token, 'base64').toString('utf-8')
const parts = decoded.split(':')
// 格式userId:startDate:endDate:type:format:timestamp:hmac
// 但 type/format 不含冒号,所以按 7 段拆分
if (parts.length !== 7) return null
const [userIdStr, startDate, endDate, type, format, timestamp, hmac] = parts
const userId = parseInt(userIdStr)
const ts = parseInt(timestamp)
if (isNaN(userId) || isNaN(ts)) return null
if (Date.now() - ts > DOWNLOAD_TOKEN_EXPIRY) return null
const payload = `${userIdStr}:${startDate}:${endDate}:${type}:${format}:${timestamp}`
const expectedHmac = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
if (hmac !== expectedHmac) return null
if (!VALID_TYPES.includes(type) || !VALID_FORMATS.includes(format)) return null
if (!DATE_REGEX.test(startDate) || !DATE_REGEX.test(endDate)) return null
return { userId, startDate, endDate, type, format }
} catch {
return null
}
}
/** 准备导出:生成短期签名 token需登录认证 */
router.get('/prepare', async (req: AuthRequest, res: Response) => {
try {
const { startDate, endDate, type = 'all', format = 'csv' } = req.query
if (!startDate || !endDate || !DATE_REGEX.test(startDate as string) || !DATE_REGEX.test(endDate as string)) {
return res.status(400).json({ code: 40001, message: '日期参数无效' })
}
if (!VALID_TYPES.includes(type as string)) {
return res.status(400).json({ code: 40001, message: '类型参数无效' })
}
if (!VALID_FORMATS.includes(format as string)) {
return res.status(400).json({ code: 40001, message: '格式参数无效' })
}
const token = createDownloadToken(req.userId!, startDate as string, endDate as string, type as string, format as string)
res.json({ code: 0, data: { token } })
} catch (err) {
console.error('[Export] prepare error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 流式导出交易数据(使用短期签名 token 认证,无需 Authorization header */
router.get('/', async (req: AuthRequest, res: Response) => {
try {
// 从 query.token 读取签名 token 并验证
const token = req.query.token as string
if (!token) {
return res.status(401).json({ code: 40100, message: '缺少下载令牌' })
}
const decoded = verifyDownloadToken(token)
if (!decoded) {
return res.status(401).json({ code: 40100, message: '下载令牌无效或已过期' })
}
const { userId, startDate, endDate, type, format } = decoded
const isCsv = format === 'csv'
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '')
const filename = isCsv ? `export_${today}.csv` : `export_${today}.json`
// 构建查询条件
let where = 'WHERE t.user_id = ? AND t.date >= ? AND t.date <= ?'
const params: any[] = [userId, startDate, endDate]
if (type !== 'all') {
where += ' AND t.type = ?'
params.push(type)
}
// 先统计数量
const [countResult] = await pool.query(
`SELECT COUNT(*) as total FROM transactions t ${where}`,
params
)
const total = (countResult as any[])[0].total
if (total === 0) {
// 空数据直接返回
if (isCsv) {
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename=${filename}`)
res.send('\uFEFF日期,类型,分类,金额,备注\n')
} else {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename=${filename}`)
res.send('[]')
}
return
}
// 设置响应头
if (isCsv) {
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
} else {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
}
res.setHeader('Content-Disposition', `attachment; filename=${filename}`)
// 超时保护
const timeout = setTimeout(() => {
if (!res.writableEnded) {
res.end()
}
}, EXPORT_TIMEOUT)
if (total < LARGE_DATA_THRESHOLD) {
// 小数据量:内存流
const [rows] = await pool.query(
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.type,
COALESCE(c.name, '未分类') as category_name,
t.amount, COALESCE(t.note, '') as note
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${where}
ORDER BY t.date ASC, t.created_at ASC`,
params
)
const data = rows as any[]
const readable = new Readable({
read() {}
})
if (isCsv) {
readable.push('\uFEFF日期,类型,分类,金额,备注\n')
for (const row of data) {
const note = String(row.note).replace(/"/g, '""')
const typeLabel = row.type === 'expense' ? '支出' : '收入'
readable.push(`${row.date},${typeLabel},${row.category_name},${row.amount},"${note}"\n`)
}
} else {
readable.push('[')
for (let i = 0; i < data.length; i++) {
const row = data[i]
const entry = JSON.stringify({
date: row.date,
type: row.type,
category: row.category_name,
amount: row.amount,
note: row.note
})
readable.push(i === 0 ? entry : ',' + entry)
}
readable.push(']')
}
readable.push(null)
readable.on('end', () => {
clearTimeout(timeout)
})
readable.on('error', () => {
clearTimeout(timeout)
})
readable.on('close', () => {
clearTimeout(timeout)
})
readable.pipe(res)
} else {
// 大数据量:临时文件流
const tmpDir = os.tmpdir()
const tmpFile = path.join(tmpDir, `export_${Date.now()}_${userId}.${format}`)
// 分批查询并写入临时文件
const BATCH_SIZE = 5000
let offset = 0
const writeStream = fs.createWriteStream(tmpFile, { encoding: 'utf-8' })
if (isCsv) {
writeStream.write('\uFEFF日期,类型,分类,金额,备注\n')
} else {
writeStream.write('[')
}
let isFirst = true
while (offset < total) {
const [rows] = await pool.query(
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.type,
COALESCE(c.name, '未分类') as category_name,
t.amount, COALESCE(t.note, '') as note
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${where}
ORDER BY t.date ASC, t.created_at ASC
LIMIT ? OFFSET ?`,
[...params, BATCH_SIZE, offset]
)
const data = rows as any[]
for (const row of data) {
if (isCsv) {
const note = String(row.note).replace(/"/g, '""')
const typeLabel = row.type === 'expense' ? '支出' : '收入'
writeStream.write(`${row.date},${typeLabel},${row.category_name},${row.amount},"${note}"\n`)
} else {
const entry = JSON.stringify({
date: row.date,
type: row.type,
category: row.category_name,
amount: row.amount,
note: row.note
})
writeStream.write(isFirst ? entry : ',' + entry)
isFirst = false
}
}
offset += BATCH_SIZE
}
if (!isCsv) {
writeStream.write(']')
}
writeStream.end()
// 等待写入完成
await new Promise<void>((resolve, reject) => {
writeStream.on('finish', resolve)
writeStream.on('error', reject)
})
// 流式返回临时文件
const readStream = fs.createReadStream(tmpFile, { encoding: 'utf-8' })
const cleanup = () => {
clearTimeout(timeout)
// 删除临时文件
fs.unlink(tmpFile, (err) => {
if (err) console.error('[Export] 删除临时文件失败:', err)
})
}
readStream.on('end', cleanup)
readStream.on('error', cleanup)
readStream.on('close', cleanup)
await pipeline(readStream, res)
}
} catch (err) {
console.error('[Export] GET error:', err)
if (!res.headersSent) {
res.status(500).json({ code: 50000, message: '服务器错误' })
} else {
res.end()
}
}
})
export default router

View File

@@ -5,6 +5,36 @@ import { requireAdmin } from '../middleware/requireAdmin'
const router = Router() const router = Router()
/** 当前用户的反馈列表 */
router.get('/mine', authMiddleware, async (req, res) => {
try {
const userId = (req as any).userId
const page = Math.max(1, parseInt(req.query.page as string) || 1)
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20))
const offset = (page - 1) * pageSize
const [countResult] = await pool.execute(
'SELECT COUNT(*) as total FROM feedbacks WHERE user_id = ?',
[userId]
)
const total = (countResult as any[])[0].total
const [rows] = await pool.execute(
`SELECT id, type, content, contact, status, admin_reply, created_at
FROM feedbacks
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?`,
[userId, pageSize, offset]
)
res.json({ code: 0, data: { list: rows, total, page, pageSize } })
} catch (error) {
console.error('Get my feedback error:', error)
res.status(500).json({ code: 50000, message: '获取失败' })
}
})
/** 用户提交反馈 */ /** 用户提交反馈 */
router.post('/', authMiddleware, async (req, res) => { router.post('/', authMiddleware, async (req, res) => {
try { try {
@@ -52,10 +82,11 @@ router.get('/', requireAdmin, async (req, res) => {
) )
const total = (countResult as any[])[0].total const total = (countResult as any[])[0].total
// LIMIT/OFFSET 使用参数化查询
const [rows] = await pool.execute( const [rows] = await pool.execute(
`SELECT f.*, u.nickname, u.avatar_url `SELECT f.*, u.nickname, u.avatar_url
FROM feedbacks f FROM feedbacks f
LEFT JOIN users u ON f.user_id = u.id LEFT JOIN users u ON f.user_id = u.id AND u.deleted_at IS NULL
WHERE ${where} WHERE ${where}
ORDER BY f.created_at DESC ORDER BY f.created_at DESC
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,

View File

@@ -246,7 +246,7 @@ router.get('/:id/members', async (req: AuthRequest, res: Response) => {
`SELECT gm.user_id, gm.role, gm.created_at as joined_at, `SELECT gm.user_id, gm.role, gm.created_at as joined_at,
u.nickname, u.avatar_url u.nickname, u.avatar_url
FROM group_members gm FROM group_members gm
LEFT JOIN users u ON gm.user_id = u.id LEFT JOIN users u ON gm.user_id = u.id AND u.deleted_at IS NULL
WHERE gm.group_id = ? WHERE gm.group_id = ?
ORDER BY gm.role DESC, gm.created_at ASC`, ORDER BY gm.role DESC, gm.created_at ASC`,
[groupId] [groupId]

Some files were not shown because too many files have changed in this diff Show More