Compare commits

..

53 Commits

Author SHA1 Message Date
324a3ec5c9 fix: 键盘收起后添加底部保存按钮
- 记账页面添加底部保存按钮,键盘收起时显示
- 预算页面添加底部保存按钮,键盘收起时显示
- 按钮根据编辑/新增模式显示不同文案
- 正确处理底部安全区适配
2026-06-15 16:45:18 +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
6ef44805f5 fix: 修复埋点查询 LIMIT/OFFSET 参数类型错误
- mysql2 execute 对 LIMIT/OFFSET 参数类型要求严格
- 改为直接嵌入 SQL 字符串(pSize 和 offset 已是安全的数字)
2026-06-08 15:56:16 +08:00
50c41d8759 fix: 埋点统计和日志查询修复
- track.vue: 修复日期格式化(处理 ISO 格式)
- track.vue: 筛选标签改为可滚动
- logs.ts: 修复 queryLogs 参数传递(移除 undefined date)
2026-06-08 15:50:30 +08:00
b21c90c560 fix: 修复 feedbacks 表 TEXT 类型默认值问题
- admin_reply TEXT 不能有 DEFAULT '',移除默认值
- 此问题导致 feedbacks 和 track_events 表都创建失败
2026-06-08 15:42:17 +08:00
7f6da1fa10 fix: 恢复使用服务器本地时间,不硬编码时区
- logger.ts: 使用 new Date() 服务器本地时间
- logs.ts: 统计使用服务器本地时间
- 时区显示由客户端处理
2026-06-08 15:37:21 +08:00
3ce5ff4b65 fix: 日志时区修复 + 日期选择器
- logger.ts: 使用 UTC+8 时区记录日志
- logs.ts: 统计使用 UTC+8 今日日期
- logs.vue: 日期选择器改用 picker 组件
- 移除未使用的 showDatePicker 变量
2026-06-08 15:35:50 +08:00
fc3491742f fix: 全面检查修复 — 路由顺序 + 安全加固
- logs.ts: /track/stats 和 /track/list 移到 /:date 之前,修复路由匹配问题
- config.ts: 添加配置键白名单校验,限制值长度 500 字符
- track.ts: 添加输入校验,限制事件数量和字段长度
- index.ts: /api/track 添加 rate limiter
2026-06-08 15:27:57 +08:00
a1e3106899 feat: 埋点统计系统 — 数据库存储 + 管理后台查询
后端:
- track_events 表存储埋点数据(事件类型、页面、数据、用户)
- track 路由改为写入数据库
- logs 路由新增 /track/stats 和 /track/list 查询接口

前端:
- 新增埋点统计页面(今日事件、7天趋势、热门事件)
- 支持按事件类型筛选
- 管理后台添加「埋点统计」入口
2026-06-08 15:19:12 +08:00
162f2dc89c fix: 日志系统修复
- userId 延迟到 finish 事件获取(authMiddleware 已执行)
- 日志记录 UTC 时间戳,支持前端按用户时区显示
- 日期筛选和级别筛选 watch 监听修复
- 添加 showDatePicker 变量定义
2026-06-08 15:06:29 +08:00
860c599b7c fix: 系统检查修复 — 安全和稳定性
后端:
- config.ts 使用 requireAdmin 中间件,统一响应格式
- feedback.ts 使用 requireAdmin 中间件,移除重复权限检查
- feedback.ts 添加 affectedRows 检查(反馈不存在时返回 404)
- 全局错误处理中间件兜底未捕获异常

前端:
- request.ts 添加 15 秒请求超时设置
2026-06-08 14:48:39 +08:00
dcab68b3a8 fix: 修复 logs.ts TypeScript 类型错误 — level 参数类型处理 2026-06-08 14:34:45 +08:00
cc09a177ef feat: 管理后台日志查询系统
后端:
- 新增 /api/logs 路由,支持日志文件列表、统计、查询
- 按日期/级别/关键词筛选日志

前端:
- 新增日志管理页面,展示今日统计和7天趋势
- 支持按级别筛选和关键词搜索
- 管理后台添加「系统日志」入口
2026-06-08 14:33:09 +08:00
0de4c9832c feat: 请求日志 + 前端埋点系统
后端:
- 新增 logger 中间件,记录所有请求到 logs/ 目录
- 新增 /api/track 接口接收前端埋点数据

前端:
- 新增 tracker 工具,支持页面访问/操作/错误埋点
- request.ts 添加 API 错误自动埋点
- App.vue 初始化全局错误追踪

文档:
- CLAUDE.md 更新项目结构
- DEV.md 添加埋点和日志说明
2026-06-08 14:28:11 +08:00
2f122d0a96 docs: 更新 DEV.md — 后端 API 响应格式规范
- 统一响应格式 { code: 0, data: ... }
- 错误码规范说明
2026-06-08 11:30:58 +08:00
d681dc67a3 fix: 后端 API 响应格式统一 — config 和 feedback 路由
- config.ts: 响应格式改为 { code: 0, data: ... }
- feedback.ts: 响应格式改为 { code: 0, data: ... }
- 修复前端 request 函数因响应格式不标准导致的请求失败
2026-06-08 11:30:38 +08:00
9423f15f29 docs: 更新 DEV.md — API 调用规范和页面加载模式
- request 函数正确调用格式说明
- 页面数据加载标准模式(initialLoaded 守卫)
2026-06-08 11:25:03 +08:00
65c2a81486 fix: API 调用格式修复 — request 参数传递方式
- feedback.ts: 修复 request 调用格式,使用 { url, data } 对象
- config.ts: 修复 request 调用格式,使用 { url } 对象
2026-06-08 11:23:43 +08:00
24166aeb62 fix: 系统检查修复 — 布局一致性 + bug 修复
- 公告图片样式统一:圆角 $radius-md,间距 $space-sm
- 首页紧急公告弹窗样式改为 SCSS 变量
- 新增 modal-mask-high mixin 统一高层级弹窗
- admin/config.vue 添加 initialLoaded 守卫避免重复请求
- notifications/index.vue 添加 initialLoaded 守卫
- notification store 所有方法添加 try-catch 错误处理
2026-06-08 11:21:08 +08:00
ecfb67872d fix: sys_config seed 补充 app_name 字段
- seed.sql 添加 app_name 默认值
- init.ts 迁移中添加 app_name 兼容已有数据库
2026-06-08 11:13:27 +08:00
8b5589192d fix: 修复编辑公告保存失败 — 图片上传逻辑
- uploadImageIfNeeded 增加 notif_ 前缀判断
- 编辑保留原图时不再尝试上传本地文件
2026-06-08 11:04:33 +08:00
5124b390a7 fix: 移除 configStore 默认值,等待后端返回
- appName/appSlogan/appDescription 改为 computed,从 config 读取
- 后端配置加载完成前为空字符串
2026-06-08 11:02:16 +08:00
6af876a8f4 feat: 品牌文案配置化 + 全页面下拉刷新
- 新增 configStore 管理品牌文案(appName, appSlogan, appDescription)
- 关于弹窗、分享标题使用 configStore 读取品牌名
- 所有数据页面添加下拉刷新功能
- 所有数据页面 onShow 时自动刷新数据
2026-06-08 10:59:09 +08:00
62ca400b64 feat: 反馈回复通知 + 修复导入
- 管理员回复反馈时自动发送个人通知给用户
- 修复 config.ts 和 feedback.ts 的 pool 导入方式
2026-06-08 10:40:42 +08:00
2705335002 feat: 系统配置 + 用户签名
- 新增 sys_config 表,存储应用版本号、标语、描述
- 关于小菜弹窗版本号改为从数据库动态获取
- 用户表新增 slogan 字段(个性签名)
- 编辑资料页面支持修改签名
- 管理后台新增系统配置管理页面
- 更新 CLAUDE.md 文档
2026-06-08 10:37:04 +08:00
29614fdb70 feat: 隐私政策 + 意见反馈系统
- 新增隐私政策页面,满足小程序审核要求
- 新增意见反馈功能(用户提交 + 管理员查看)
- 后端新增 feedbacks 表和反馈 API
- 管理后台新增反馈管理入口
- 我的页面添加意见反馈、隐私政策入口
2026-06-08 10:16:28 +08:00
8cf240f76b feat: 新增小程序分享功能 — 全局默认+页面级定制 2026-06-08 10:09:53 +08:00
wangxiaogang
1abc55323d feat: 用户体验优化 — 首页加载/继续记账/登录恢复
## 首页加载优化
- 分级加载:首屏只请求 overview + recentTx(2个请求)
- 数据新鲜度:30秒内不重复请求
- 次要数据异步加载:budget + todayData
- 非关键数据后台加载:userInfo + notifications

## 继续记账功能
- SaveSuccess 组件新增「继续记一笔」按钮
- 新增模式显示继续按钮,编辑模式自动返回
- 继续记账时保留分类和日期,清空金额和备注

## 登录失败恢复
- 登录重试机制:最多3次,间隔1秒
- 失败后显示提示而非静默等待

## 群组账单逻辑修正
- 群组账单 = 所有群组成员的个人账单之和
- 修改查询:WHERE user_id IN (群组成员)
2026-06-07 17:49:46 +08:00
wangxiaogang
bd7af8e512 feat: 全面系统检查与修复 — 51项问题修复
## 重大修复

### 安全性修复
- 修复 notification read-all SQL 运算符优先级 bug
- 修复 link_url XSS 漏洞(验证 URL 协议)
- 修复 LIKE 通配符注入(转义 % 和 _)
- 修复 uploadImageIfNeeded 运算符优先级 bug
- 401 时清理所有本地存储(token/nickname/avatar/role/group)

### 数据完整性修复
- 修复公告已读状态共享问题(新建 notification_reads 表)
- 修复群组账单数据缺失(退出群组时保留 group_id)
- 修复群组解散后邀请码仍可加入
- 修复分类迁移未校验目标类型
- 修复群组公告缺少 group_id 必填校验

### 功能修复
- 修复 category PUT /sort 路由冲突
- 修复 GROUP BY 不完整问题
- 修复 budget API 类型不匹配
- 修复 categoryStore.migrateCategory 不刷新本地数据
- 修复 groupStore 并发请求问题
- 修复账单页覆盖 store 数据
- 修复群组预算查询返回 0 而非 null
- 修复通知页面 onShow 不刷新列表
- 修复统计页面不必要的重复请求

### 用户体验优化
- 添加通知详情查看功能(弹窗)
- 添加通知图片服务器上传
- 添加 Markdown 富文本工具栏
- 添加管理页面客户端认证检查
- 添加管理员公告页面下拉刷新
- 添加数据导出进度反馈
- 添加账单删除后筛选金额更新
- Numpad 添加安全区域 padding

### 代码质量提升
- 提取 requireAdmin 为共享中间件
- filter-panel 使用设计 token
- 修复 getCurrentMonth 时区不一致
- 备份功能使用分页查询避免内存问题
- 管理后台仪表盘添加缓存
- 邀请码碰撞重试后报错

## 新增文件
- server/src/middleware/requireAdmin.ts — 共享管理员权限中间件
- client/src/pages/admin/notifications/index.vue — 公告管理页面

## 数据库变更
- 新增 notification_reads 表(公告已读记录)
- 群组解散时保留 groups 记录和 transactions.group_id
2026-06-07 15:35:37 +08:00
wangxiaogang
4f42f2c342 refactor: UI/UX 全面重构 — 设计系统统一
设计系统:
- uni.scss 扩展:间距/圆角/字体/阴影/动画 token 体系
- 新建 styles/mixins.scss:20+ 公共 mixin

页面重构(12 个页面):
- 首页/记账/统计/账单/个人中心/预算/分类管理
- 群组管理/通知中心/管理员/用户管理/编辑资料

所有硬编码替换为设计 token:
- 颜色:#FFF8F0→$bg, #FFFFFF→$surface, #FF8C69→$primary 等
- 间距:40rpx→$space-xl, 24rpx→$space-md 等
- 字体:28rpx→$font-lg, 24rpx→$font-md 等
- 圆角:20rpx→$radius-lg, 40rpx→$radius-2xl 等
- 阴影:统一使用 $shadow-md/$shadow-sm

重复样式提取为 mixin:
- page-base, sticky-header, nav-bar, card, modal-mask
- tabs, tab, state-box, shimmer, flex-center 等
2026-06-06 22:37:46 +08:00
76 changed files with 9306 additions and 2064 deletions

View File

@@ -68,10 +68,14 @@ client/src/
│ ├── stats/ # 统计分析
│ ├── bills/ # 全部账单 (下拉刷新/上拉加载)
│ ├── profile/ # 我的(身份切换入口)
│ ├── profile-edit/ # 编辑资料(昵称+头像)
│ ├── profile-edit/ # 编辑资料(昵称+头像+签名
│ ├── group-manage/ # 群组管理(创建/加入/退出/解散)
│ ├── category-manage/ # 分类管理
── budget/ # 预算设置
── budget/ # 预算设置
│ ├── notifications/ # 通知中心
│ ├── feedback/ # 意见反馈
│ ├── privacy/ # 隐私政策
│ └── admin/ # 管理后台 (index/users/notifications/feedback/config)
├── components/ # 公共组件
│ ├── BudgetBar/ # 预算进度条
│ ├── CategoryIcon/ # 分类图标 (首字+颜色)
@@ -96,18 +100,26 @@ client/src/
│ ├── stats.ts # 统计接口
│ ├── user.ts # 用户信息接口 (含头像上传)
│ ├── group.ts # 群组接口
│ ├── notification.ts # 通知接口
│ ├── filter.ts # 筛选方案接口
│ ├── feedback.ts # 反馈接口
│ ├── config.ts # 系统配置接口
│ ├── admin.ts # 管理后台接口
│ └── index.ts # barrel 导出
├── utils/
│ ├── format.ts # 金额/日期格式化
│ ├── request.ts # HTTP 请求封装 (401 自动重登录+队列重试)
│ ├── system.ts # 系统信息 (statusBarHeight, capsuleRight)
── app-ready.ts # App 启动就绪机制 (waitForReady)
── app-ready.ts # App 启动就绪机制 (waitForReady)
│ └── tracker.ts # 前端埋点 (页面访问/操作/错误)
├── config.ts # 全局配置 (API_BASE)
└── static/icons/ # tabBar 图标 (PNG)
server/src/
├── index.ts # Express 入口 (CORS, JSON, 静态文件, 路由注册)
├── middleware/auth.ts # HMAC token 验证 (timingSafeEqual)
├── middleware/
│ ├── auth.ts # HMAC token 验证 (timingSafeEqual)
│ └── logger.ts # 请求日志 (记录到 logs/ 目录)
├── routes/
│ ├── auth.ts # 登录 / token 签发 (返回用户信息)
│ ├── transaction.ts # 交易 CRUD (支持 group_id 视图切换)
@@ -116,6 +128,12 @@ server/src/
│ ├── stats.ts # 统计查询 (支持 group_id)
│ ├── user.ts # 用户信息 + 头像上传
│ ├── group.ts # 群组 CRUD (创建/加入/退出/解散)
│ ├── notification.ts # 通知/公告 CRUD
│ ├── filter.ts # 筛选方案 CRUD
│ ├── feedback.ts # 用户反馈 (提交/查询/处理)
│ ├── config.ts # 系统配置 (公开读/管理员写)
│ ├── admin.ts # 管理后台 (仪表盘/用户管理)
│ ├── track.ts # 埋点数据接收
│ └── backup.ts # 备份 API
├── utils/
│ ├── backup.ts # Node.js 数据库备份 (gzip)
@@ -124,16 +142,21 @@ server/src/
├── connection.ts # MySQL 连接池
├── init.ts # 建表 + seed + 迁移 (幂等)
├── schema.sql # 表结构
└── seed.sql # 默认分类数据
└── seed.sql # 默认分类数据 + 系统配置
```
### 数据库表
- `users` — 用户 (openid, nickname, avatar_url)
- `users` — 用户 (openid, nickname, avatar_url, slogan, role)
- `categories` — 分类 (user_id=0 默认, 其他=自定义)
- `transactions` — 交易记录 (user_id, group_id, amount 分)
- `budgets` — 预算 (user_id, month, amount)
- `groups` — 群组 (name, invite_code, created_by)
- `group_members` — 群组成员 (group_id, user_id, role)
- `notifications` — 通知/公告 (type, title, content, is_pinned, is_urgent)
- `notification_reads` — 公告已读记录 (notification_id, user_id)
- `saved_filters` — 保存的筛选方案 (user_id, name, filters)
- `feedbacks` — 用户反馈 (user_id, type, content, status)
- `sys_config` — 系统配置 (config_key, config_value)
### 金额处理
- 存储单位: 分 (整数,避免浮点精度问题)
@@ -162,7 +185,8 @@ server/src/
### 一起记 (群组)
- 群组仅做数据聚合,记录始终属于记录者 (user_id)
- 个人视图: `WHERE user_id = 我` (所有记录)
- 群组视图: `WHERE group_id = X AND user_id IN (当前成员)`
- 群组视图: `WHERE user_id IN (群组成员)` — 统计所有群组成员的个人账单
- 群组账单 = 所有群组成员的个人账单之和
- 退出群组: 该用户的 group_id 设为 NULL记录回到个人视图
- 身份切换: 「我的」页底部弹窗选择,全局生效

77
DEV.md
View File

@@ -33,6 +33,82 @@
- API 地址统一在 `config.ts` 中配置,不要在各文件中硬编码
- 头像等用户文件 URL 只存文件名,前端拼接 `API_BASE + 路径 + 文件名`
## API 调用规范
### request 函数调用格式
`request` 函数**只接受一个对象参数**,格式为 `{ url, method?, data? }`
```typescript
// ✅ 正确
request({ url: '/feedback', data: params })
request({ url: '/config' })
request({ url: '/feedback', method: 'POST', data })
// ❌ 错误 — 会导致 URL 拼接为 /apiundefined
request('/feedback', { params })
request('/config', { method: 'PUT', data })
```
**原因**`request` 函数签名是 `request<T>(options: RequestOptions): Promise<T>`,第一个参数是 options 对象,不支持分开传 url 和 options。
### 后端 API 响应格式
**所有后端路由必须返回统一格式**
```typescript
// 成功
res.json({ code: 0, data: ... })
// 失败
res.status(400).json({ code: 40001, message: '错误信息' })
res.status(403).json({ code: 40300, message: '无权限' })
res.status(500).json({ code: 50000, message: '服务器错误' })
```
**错误码规范**
- `0` — 成功
- `40001` — 参数错误
- `40300` — 权限不足
- `40400` — 资源不存在
- `50000` — 服务器错误
**原因**:前端 `request` 函数检查 `data.code === 0` 判断成功,不返回标准格式会导致前端误判为失败。
### 埋点和日志
**后端请求日志**:所有请求自动记录到 `logs/YYYY-MM-DD.log`包含请求方法、URL、状态码、耗时、IP、用户ID。
**前端埋点**:使用 `@/utils/tracker` 记录关键操作:
```typescript
import { trackAction, trackError, trackApiError } from '@/utils/tracker'
trackAction('save_transaction', { amount: 100 }) // 用户操作
trackError(new Error('xxx'), { context: 'xxx' }) // 错误上报
```
### 页面数据加载模式
```typescript
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
await loadData()
initialLoaded.value = true
})
// onShow 时静默刷新,但必须有 initialLoaded 守卫
onShow(() => {
if (initialLoaded.value) loadData(true)
})
onPullDownRefresh(async () => {
await loadData()
uni.stopPullDownRefresh()
})
```
## Never 规则
- Never 修改 dist/ 目录
@@ -43,6 +119,7 @@
- Never 使用 git stash
- Never 切换分支
- Never 过度封装,保持代码简洁明了
- Never 未确定需求就开始实现
## Requirements

View File

@@ -2,24 +2,51 @@
import { onLaunch } from '@dcloudio/uni-app'
import { wxLogin, demoLogin } from '@/api/auth'
import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { saveLoginResult } from '@/utils/request'
import { markReady } from '@/utils/app-ready'
import { setupErrorTracking, trackAction, trackError } from '@/utils/tracker'
/** 登录重试最多3次 */
async function loginWithRetry(loginFn: () => Promise<any>, retries = 3): Promise<boolean> {
for (let i = 0; i < retries; i++) {
try {
const data = await loginFn()
saveLoginResult(data)
return true
} catch (e) {
console.error(`[Auth] Login attempt ${i + 1} failed:`, e)
trackError(e as Error, { context: 'login', attempt: i + 1 })
if (i < retries - 1) {
// 等待1秒后重试
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
}
return false
}
onLaunch(() => {
// 初始化错误追踪
setupErrorTracking()
trackAction('app_launch')
const groupStore = useGroupStore()
const configStore = useConfigStore()
groupStore.init()
configStore.fetchConfig()
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: async (loginRes) => {
if (loginRes.code) {
try {
saveLoginResult(await wxLogin(loginRes.code))
const success = await loginWithRetry(() => wxLogin(loginRes.code))
if (success) {
groupStore.fetchGroups()
console.log('[Auth] Login success')
} catch (e) {
console.error('[Auth] Login failed:', e)
} else {
uni.showToast({ title: '登录失败,请重试', icon: 'none', duration: 3000 })
}
}
markReady()
@@ -30,13 +57,15 @@ onLaunch(() => {
// #ifdef H5
const token = uni.getStorageSync('xc:token')
if (!token) {
demoLogin().then((data) => {
saveLoginResult(data)
loginWithRetry(demoLogin).then((success) => {
if (success) {
groupStore.fetchGroups()
console.log('[Auth] H5 demo login success')
}).catch((e) => {
console.error('[Auth] H5 demo login failed:', e)
}).finally(() => { markReady() })
} else {
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none', duration: 3000 })
}
markReady()
})
} else {
groupStore.fetchGroups()
markReady()
@@ -49,10 +78,15 @@ onLaunch(() => {
<style lang="scss">
@import './uni.scss';
// 全局盒模型
view, text, image, input, textarea, scroll-view, picker {
box-sizing: border-box;
}
page {
background-color: #FFF8F0;
background-color: $bg;
font-family: 'Nunito', 'PingFang SC', sans-serif;
color: #2D1B1B;
color: $text;
-webkit-font-smoothing: antialiased;
}
</style>

View File

@@ -10,6 +10,10 @@ export interface Dashboard {
growthPercent: number
lastMonthTxCount: number
lastMonthTxAmount: number
monthlyExpense?: number
monthlyIncome?: number
lastMonthExpense?: number
lastMonthIncome?: number
}
/** 用户信息(管理员视角) */

View File

@@ -6,6 +6,7 @@ export interface LoginResult {
userId: number
nickname: string
avatar_url: string
role?: string
}
/** H5 演示登录 */

View File

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

11
client/src/api/config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { request } from '@/utils/request'
/** 获取系统配置 */
export function getConfig(): Promise<Record<string, string>> {
return request({ url: '/config' })
}
/** 更新系统配置(管理员) */
export function updateConfig(data: Record<string, string>) {
return request({ url: '/config', method: 'PUT', data })
}

View File

@@ -0,0 +1,35 @@
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: {
type: string
content: string
contact?: string
}) {
return request({ url: '/feedback', method: 'POST', data })
}
/** 获取反馈列表(管理员) */
export function getFeedbackList(params?: { page?: number; pageSize?: number; status?: string }) {
return request<{ list: Feedback[]; total: number; page: number; pageSize: number }>({ url: '/feedback', data: params })
}
/** 更新反馈状态(管理员) */
export function updateFeedbackStatus(id: number, status: Feedback['status'], admin_reply?: string) {
return request({ url: `/feedback/${id}/status`, method: 'PUT', data: { status, admin_reply } })
}

91
client/src/api/logs.ts Normal file
View File

@@ -0,0 +1,91 @@
import { request } from '@/utils/request'
/** 日志文件信息 */
export interface LogFileInfo {
name: string
date: string
size: number
modified: string
}
/** 日志统计 */
export interface LogStats {
today: {
total: number
errors: number
slow: number
warns: number
}
daily: Array<{
date: string
total: number
errors: number
slow: number
}>
}
/** 埋点统计 */
export interface TrackStats {
today: Array<{ event: string; count: number }>
daily: Array<{ date: string; count: number }>
events: Array<{ event: string; count: number }>
}
/** 埋点事件 */
export interface TrackEvent {
id: number
event: string
page: string
data: any
user_id: number | null
nickname: string | null
created_at: string
}
/** 获取日志文件列表 */
export function getLogFiles(): Promise<LogFileInfo[]> {
return request({ url: '/logs/files' })
}
/** 获取日志统计 */
export function getLogStats(): Promise<LogStats> {
return request({ url: '/logs/stats' })
}
/** 查询日志 */
export function queryLogs(params: {
date: string
level?: string
keyword?: string
page?: number
pageSize?: number
}): Promise<{ lines: string[]; total: number; page: number; pageSize: number }> {
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 })
}
/** 获取埋点统计 */
export function getTrackStats(): Promise<TrackStats> {
return request({ url: '/logs/track/stats' })
}
/** 查询埋点事件 */
export function queryTrackEvents(params?: {
event?: string
page?: number
pageSize?: number
}): Promise<{ list: TrackEvent[]; total: number; page: number; pageSize: number }> {
// 过滤掉 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

@@ -1,4 +1,5 @@
import { request } from '@/utils/request'
import { API_BASE } from '@/config'
/** 通知 */
export interface Notification {
@@ -64,6 +65,42 @@ export function markAllRead() {
return request({ url: '/notifications/read-all', method: 'PUT' })
}
/** 上传通知图片(返回文件名) */
export function uploadNotificationImage(filePath: string): Promise<{ image_url: string }> {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('xc:token')
uni.uploadFile({
url: `${API_BASE}/notifications/upload-image`,
filePath,
name: 'file',
header: {
...(token ? { Authorization: `Bearer ${token}` } : {})
},
success: (res) => {
try {
const data = JSON.parse(res.data)
if (data.code === 0) {
resolve(data.data)
} else {
reject(data)
}
} catch {
reject({ message: '上传失败' })
}
},
fail: () => reject({ message: '网络错误' })
})
})
}
/** 获取通知图片完整 URL */
export function getNotificationImageUrl(filename: string): string {
if (!filename) return ''
// 如果已经是完整 URL 或 blob URL直接返回
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
return `${API_BASE}/notifications/image/${filename}`
}
/** 发布公告 */
export function publishNotification(data: PublishParams) {
return request<{ id: number }>({ url: '/notifications', method: 'POST', data })

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

@@ -41,3 +41,14 @@ export function getCategoryStats(month?: string, type: string = 'expense', group
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) {
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } })
}
/** 智能分类建议结果 */
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 } })
}

View File

@@ -6,6 +6,7 @@ export interface UserInfo {
id: number
nickname: string
avatar_url: string
slogan: string
role: 'user' | 'admin'
created_at: string
}
@@ -15,9 +16,11 @@ export function getUserInfo() {
return request<UserInfo>({ url: '/user/me' })
}
/** 更新昵称 */
export function updateNickname(nickname: string) {
return request({ url: '/user/me', method: 'PUT', data: { nickname } })
/** 更新昵称/签名 */
export function updateNickname(nickname: string, slogan?: string) {
const data: any = { nickname }
if (slogan !== undefined) data.slogan = slogan
return request({ url: '/user/me', method: 'PUT', data })
}
/** 上传头像(返回新 URL */

View File

@@ -0,0 +1,272 @@
<template>
<view class="amount-editor" :class="[`amount-editor--${size}`, { 'amount-editor--focused': innerFocused }]">
<view class="amount-display" @tap="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="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="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"
@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
}>(), {
modelValue: '',
max: 9999999.99,
fixed: true,
visible: true,
autoFocus: true,
size: 'large',
placeholder: '0',
unit: ''
})
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() {
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.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,9 +1,15 @@
<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)">
<text class="key-text">{{ key }}</text>
</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-arrow"></view>
<view class="backspace-line"></view>
@@ -16,73 +22,118 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount } from 'vue'
import Icon from '@/components/Icon/Icon.vue'
import {
applyAmountKey,
clampCursorIndex,
type AmountKey
} from '@/components/AmountEditor/amount-edit'
const props = withDefaults(defineProps<{
/** 金额字符串v-model */
modelValue?: string
/** 光标位置 */
cursorIndex?: number
/** 最大金额(元) */
max?: number
/** 是否固定在底部(弹窗中使用时设为 false */
fixed?: boolean
/** 是否显示fixed 模式下控制滑入/滑出) */
visible?: boolean
}>(), {
modelValue: '',
max: 9999999.99
max: 9999999.99,
fixed: true,
visible: true
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'update:cursorIndex', index: number): 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 effectiveCursorIndex = computed(() =>
clampCursorIndex(amountStr.value, props.cursorIndex ?? amountStr.value.length)
)
let deleteTimer: ReturnType<typeof setTimeout> | null = null
let deleteLongPressed = false
function vibrate() {
try { uni.vibrateShort({ type: 'light' }) } catch {}
}
function emitValue(val: string) {
emit('update:modelValue', val)
}
function onKeyTap(key: string) {
function applyKey(key: AmountKey | 'delete') {
vibrate()
const current = amountStr.value
if (key === '.') {
if (current.includes('.')) return
if (!current || current === '0') { emitValue('0.'); return }
const result = applyAmountKey(
amountStr.value,
effectiveCursorIndex.value,
key,
{ max: props.max }
)
if (result.accepted) {
emit('update:modelValue', result.value)
emit('update:cursorIndex', result.cursorIndex)
}
}
let next: string
if (!current || current === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = current + key
function onKeyTap(key: AmountKey) {
applyKey(key)
}
if (next.includes('.')) {
const decimals = next.split('.')[1]
if (decimals && decimals.length > 2) return
function onDeleteTap() {
if (deleteLongPressed) {
deleteLongPressed = false
return
}
if (next.length > 10) return
const num = parseFloat(next)
if (!isNaN(num) && num > props.max) return
emitValue(next)
applyKey('delete')
}
function onDelete() {
function doDeleteRepeat() {
deleteLongPressed = true
const result = applyAmountKey(
amountStr.value,
effectiveCursorIndex.value,
'delete',
{ max: props.max }
)
if (result.accepted) {
vibrate()
const current = amountStr.value
if (current.length > 1) {
emitValue(current.slice(0, -1))
emit('update:modelValue', result.value)
emit('update:cursorIndex', result.cursorIndex)
deleteTimer = setTimeout(doDeleteRepeat, 120)
} else {
emitValue('')
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() {
vibrate()
emit('confirm')
@@ -90,40 +141,91 @@ function onConfirm() {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.numpad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
padding: 0 32rpx;
gap: $space-sm;
padding: $space-sm $space-xl;
// 非 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 {
height: 96rpx;
border-radius: 24rpx;
background: #FFF8F0;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-xl;
background: $surface;
border: 2rpx solid $border;
box-shadow: $shadow-clay;
@include flex-center;
transition: all $transition-fast;
-webkit-tap-highlight-color: transparent;
user-select: none;
&:active { background: #F0E0D6; }
&:active {
background: $surface-warm;
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
}
.key-text {
font-family: 'Fredoka', sans-serif;
font-size: 44rpx;
font-weight: 500;
color: #2D1B1B;
color: $text;
}
.key.fn {
background: #FFE8E0;
&:active { background: #FFD0C0; }
background: $primary-light;
border-color: #FFD0C0;
&:active {
background: #FFD0C0;
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
}
.key.eq {
background: linear-gradient(135deg, #FF8C69, #E67355);
box-shadow: 0 4rpx 16rpx rgba(255, 140, 105, 0.3);
&:active { background: #E67355; }
background: linear-gradient(135deg, $primary, $primary-dark);
border: none;
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 图标
@@ -139,8 +241,8 @@ function onConfirm() {
top: 50%;
width: 16rpx;
height: 16rpx;
border-left: 4rpx solid #FF8C69;
border-bottom: 4rpx solid #FF8C69;
border-left: 4rpx solid $primary;
border-bottom: 4rpx solid $primary;
transform: translateY(-50%) rotate(45deg);
}
@@ -150,7 +252,7 @@ function onConfirm() {
top: 50%;
width: 26rpx;
height: 4rpx;
background: #FF8C69;
background: $primary;
transform: translateY(-50%);
border-radius: 2rpx;
}

View File

@@ -1,9 +1,19 @@
<template>
<view class="save-success" v-if="visible" @tap="hide">
<view class="success-icon" :class="{ show: animating }">
<view class="save-success" v-if="visible">
<view class="success-content" :class="{ show: animating }">
<view class="success-icon">
<Icon name="check" :size="80" color="#FFFFFF" />
</view>
<text class="success-text" :class="{ show: animating }">{{ text }}</text>
<text class="success-text">{{ text }}</text>
<view class="success-actions" v-if="showContinue">
<view class="action-btn continue" @tap="onContinue">
<text class="action-text">继续记一笔</text>
</view>
<view class="action-btn back" @tap="onBack">
<text class="action-text">返回</text>
</view>
</view>
</view>
</view>
</template>
@@ -14,51 +24,61 @@ import Icon from '@/components/Icon/Icon.vue'
const props = withDefaults(defineProps<{
visible: boolean
text?: string
}>(), { text: '保存成功' })
showContinue?: boolean
}>(), { text: '保存成功', showContinue: false })
const emit = defineEmits(['update:visible'])
const emit = defineEmits(['update:visible', 'continue', 'back'])
const animating = ref(false)
watch(() => props.visible, (val) => {
if (val) {
setTimeout(() => { animating.value = true }, 50)
setTimeout(() => { hide() }, 2000)
// 如果不显示继续按钮,自动关闭
if (!props.showContinue) {
setTimeout(() => { onBack() }, 2000)
}
} else {
animating.value = false
}
})
function hide() {
function onContinue() {
animating.value = false
setTimeout(() => { emit('update:visible', false) }, 300)
setTimeout(() => {
emit('update:visible', false)
emit('continue')
}, 300)
}
function onBack() {
animating.value = false
setTimeout(() => {
emit('update:visible', false)
emit('back')
}, 300)
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.save-success {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
@include flex-center;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
}
.success-icon {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: linear-gradient(135deg, #7BC67E, #5BA85E);
.success-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transform: scale(0);
transform: scale(0.8);
opacity: 0;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
@@ -68,18 +88,47 @@ function hide() {
}
}
.success-text {
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
margin-top: 24rpx;
transform: translateY(20rpx);
opacity: 0;
transition: all 0.3s ease 0.2s;
&.show {
transform: translateY(0);
opacity: 1;
.success-icon {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: linear-gradient(135deg, $success, #5BA85E);
@include flex-center;
}
.success-text {
font-size: $font-xl;
font-weight: 600;
color: $surface;
margin-top: $space-lg;
}
.success-actions {
display: flex;
gap: $space-md;
margin-top: $space-2xl;
}
.action-btn {
padding: $space-sm $space-xl;
border-radius: $radius-xl;
min-width: 200rpx;
@include flex-center;
&.continue {
background: $primary;
&:active { opacity: 0.8; }
}
&.back {
background: rgba(255, 255, 255, 0.2);
&:active { background: rgba(255, 255, 255, 0.3); }
}
}
.action-text {
font-size: $font-lg;
font-weight: 600;
color: $surface;
}
</style>

View File

@@ -73,7 +73,7 @@ defineEmits(['item-tap', 'delete'])
const creatorAvatarUrl = computed(() => {
if (!props.item.creator_avatar) return ''
if (props.item.creator_avatar.startsWith('http')) return 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

View File

@@ -6,5 +6,23 @@ export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
// #ifdef MP-WEIXIN
// 全局分享配置,页面可通过定义 onShareAppMessage/onShareTimeline 覆盖
app.mixin({
onShareAppMessage() {
return {
title: '小菜记账 — 轻松记账,快乐生活',
path: '/pages/index/index'
}
},
onShareTimeline() {
return {
title: '小菜记账 — 轻松记账,快乐生活'
}
}
})
// #endif
return { app }
}

View File

@@ -35,21 +35,31 @@
"path": "pages/profile/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的"
"navigationBarTitleText": "我的",
"enablePullDownRefresh": true
}
},
{
"path": "pages/category-manage/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "分类管理"
"navigationBarTitleText": "分类管理",
"enablePullDownRefresh": true
}
},
{
"path": "pages/recurring/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "周期账单"
}
},
{
"path": "pages/budget/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "预算设置"
"navigationBarTitleText": "预算设置",
"enablePullDownRefresh": true
}
},
{
@@ -63,7 +73,8 @@
"path": "pages/group-manage/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "一起记"
"navigationBarTitleText": "一起记",
"enablePullDownRefresh": true
}
},
{
@@ -71,14 +82,15 @@
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "通知中心",
"enablePullDownRefresh": false
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "管理后台"
"navigationBarTitleText": "管理后台",
"enablePullDownRefresh": true
}
},
{
@@ -86,7 +98,61 @@
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "用户管理",
"enablePullDownRefresh": false
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin/notifications",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "公告管理",
"enablePullDownRefresh": true
}
},
{
"path": "pages/privacy/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "隐私政策"
}
},
{
"path": "pages/feedback/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "意见反馈"
}
},
{
"path": "pages/admin/feedback",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "反馈管理",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin/config",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "系统配置",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin/logs",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "系统日志",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin/track",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "埋点统计",
"enablePullDownRefresh": true
}
}
],

View File

@@ -19,16 +19,26 @@
</view>
</view>
<view class="amount-display">
<text class="currency">¥</text>
<text class="digits">{{ displayAmount }}</text>
<text class="cursor">|</text>
</view>
<AmountEditor
ref="amountEditorRef"
v-model="amountStr"
:fixed="true"
size="large"
:auto-focus="true"
@focus="keyboardVisible = true"
@blur="keyboardVisible = false"
@confirm="save"
/>
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat">
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat" @tap="blurAmountEditor">
<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 }" @tap="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>
</view>
</view>
@@ -36,20 +46,31 @@
<view class="extra-fields">
<picker 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" />
<text class="extra-text">{{ dateLabel }}</text>
</view>
</picker>
<view class="extra-row">
<Icon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="50" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="blurAmountEditor" />
</view>
</view>
<Numpad v-model="amountStr" @confirm="save" />
<!-- 底部保存按钮键盘收起时显示 -->
<view class="save-bar" v-if="!showSuccess && !keyboardVisible">
<view class="save-btn" @tap="save">
<text class="save-btn-text">{{ editId ? '保存修改' : '保存' }}</text>
</view>
</view>
<SaveSuccess v-model:visible="showSuccess" :text="editId ? '修改成功' : '记账成功'" />
<SaveSuccess
v-model:visible="showSuccess"
:text="editId ? '修改成功' : '记账成功'"
:showContinue="!editId"
@continue="onContinue"
@back="onBack"
/>
</view>
</template>
@@ -59,7 +80,8 @@ import { onShow } from '@dcloudio/uni-app'
import { useCategoryStore } from '@/stores/category'
import { useTransactionStore } from '@/stores/transaction'
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 Icon from '@/components/Icon/Icon.vue'
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
@@ -72,6 +94,9 @@ const type = ref<'expense' | 'income'>('expense')
const amountStr = ref('0')
const selectedCat = ref<number | null>(null)
const note = ref('')
const suggestedCatId = ref<number | null>(null)
const suggestConfidence = ref(0)
let suggestTimer: ReturnType<typeof setTimeout> | null = null
const now = new Date()
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const selectedDate = ref(localToday)
@@ -80,6 +105,9 @@ const editId = ref<number | null>(null)
const showSuccess = ref(false)
const scrollToCat = ref('')
const todayStr = localToday
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const keyboardVisible = ref(false)
// Remember last selected category per type
const lastCatByType: Record<string, number | null> = { expense: null, income: null }
@@ -88,10 +116,6 @@ const dateLabel = computed(() => {
return `${d.getMonth() + 1}${d.getDate()}`
})
const displayAmount = computed(() => {
if (!amountStr.value) return ''
return amountStr.value
})
const currentCategories = computed(() => catStore.getByType(type.value))
@@ -115,11 +139,51 @@ 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) {
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) {
selectedCat.value = result.category.id
}
} else {
suggestedCatId.value = null
suggestConfidence.value = 0
}
} catch {
suggestedCatId.value = null
}
}, 600)
})
onMounted(async () => {
await waitForReady()
// 加载分类列表,失败时重试一次
try {
await catStore.fetchCategories()
} catch {
try {
await catStore.fetchCategories()
} catch {
uni.showToast({ title: '分类加载失败', icon: 'none' })
}
}
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)) {
type.value = page.options.type
}
@@ -140,6 +204,7 @@ onMounted(async () => {
// 等待分类列表渲染完成后滚动到选中项
nextTick(() => {
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
amountEditorRef.value?.focus()
})
} else {
uni.showToast({ title: '记录不存在', icon: 'none' })
@@ -162,6 +227,10 @@ function onDateChange(e: any) {
selectedDate.value = e.detail.value
}
function blurAmountEditor() {
amountEditorRef.value?.blur()
}
async function save() {
if (saving.value) return
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
@@ -189,7 +258,10 @@ async function save() {
await txStore.addTransaction(data)
}
showSuccess.value = true
setTimeout(() => uni.navigateBack(), 2500)
// 编辑模式自动返回,新增模式显示继续按钮
if (editId.value) {
setTimeout(() => uni.navigateBack(), 2000)
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
@@ -197,6 +269,20 @@ async function save() {
}
}
/** 继续记一笔 */
function onContinue() {
// 重置表单,保留分类和日期
amountStr.value = ''
note.value = ''
showSuccess.value = false
nextTick(() => amountEditorRef.value?.focus())
}
/** 返回上一页 */
function onBack() {
uni.navigateBack()
}
function goBack() {
// 保存成功后直接返回,不弹确认框
if (showSuccess.value) return
@@ -216,51 +302,85 @@ function goBack() {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
background: $bg;
display: flex;
flex-direction: column;
// AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
// 同时留出保存按钮的位置,确保内容不被遮挡
$numpad-h: 464rpx;
$save-bar-h: 144rpx;
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + env(safe-area-inset-bottom));
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
// 底部保存按钮栏
.save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: $space-md $space-xl;
padding-bottom: calc(#{$space-md} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$space-md} + env(safe-area-inset-bottom));
background: $surface;
border-top: 2rpx solid $border;
z-index: 50;
}
.status-bar {
background: #FFF8F0;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 40rpx;
}
.nav-back {
width: 88rpx;
height: 88rpx;
.save-btn {
height: 96rpx;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
&:active { background: #F0E0D6; }
&:active {
opacity: 0.8;
transform: scale(0.98);
}
}
.title { font-size: 32rpx; font-weight: 500; color: #2D1B1B; }
.save-btn-text {
font-size: $font-xl;
font-weight: 600;
color: $surface;
}
.type-toggle-wrap { display: flex; justify-content: center; margin: 16rpx 0; }
.header-fixed {
@include sticky-header;
}
.status-bar {
@include status-bar;
}
.header {
@include nav-bar;
justify-content: space-between;
}
.nav-back {
@include nav-back;
border-radius: 50%;
&:active { background: $border; }
}
.title { font-size: $font-xl; font-weight: 500; color: $text; }
.type-toggle-wrap { display: flex; justify-content: center; margin: $space-sm 0; }
.type-toggle {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
background: $surface-warm;
border-radius: $radius-lg;
padding: $space-xs;
position: relative;
}
@@ -270,62 +390,30 @@ function goBack() {
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #8B7E7E;
border-radius: 20rpx;
font-size: $font-lg;
color: $text-sec;
border-radius: $radius-lg;
position: relative;
z-index: 1;
transition: color 0.2s;
transition: color $transition-normal;
&.active { color: #FF8C69; font-weight: 600; }
&.active { color: $primary; font-weight: 600; }
}
.slider {
position: absolute;
top: 8rpx;
left: 8rpx;
top: $space-xs;
left: $space-xs;
width: 160rpx;
height: 72rpx;
background: #FFFFFF;
border-radius: 20rpx;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.08);
transition: transform 0.2s;
background: $surface;
border-radius: $radius-lg;
box-shadow: $shadow-sm;
transition: transform $transition-normal;
}
.income .slider { transform: translateX(160rpx); }
.amount-display {
text-align: center;
padding: 32rpx 40rpx 24rpx;
}
.currency {
font-family: 'Fredoka', sans-serif;
font-size: 48rpx;
font-weight: 500;
color: #BFB3B3;
margin-right: 8rpx;
}
.digits {
font-family: 'Fredoka', sans-serif;
font-size: 88rpx;
font-weight: 700;
color: #2D1B1B;
font-variant-numeric: tabular-nums;
}
.cursor {
font-family: 'Fredoka', monospace;
font-size: 88rpx;
font-weight: 300;
color: #FF8C69;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.category-scroll {
max-height: 320rpx;
@@ -334,8 +422,8 @@ function goBack() {
.category-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
padding: 8rpx 0;
gap: $space-sm;
padding: $space-xs 0;
}
.cat-item {
@@ -344,19 +432,49 @@ function goBack() {
align-items: center;
gap: 6rpx;
padding: 12rpx 0;
position: relative;
&:active { transform: scale(0.95); }
&.suggested {
.cat-icon-wrap {
border: 3rpx dashed $primary;
border-radius: $radius-lg;
padding: 6rpx;
margin: -6rpx;
}
}
}
.cat-name { font-size: 22rpx; color: #8B7E7E; }
.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.active {
color: #FF8C69;
color: $primary;
font-weight: 600;
}
.extra-fields {
padding: 16rpx 40rpx;
padding: $space-sm $space-xl;
display: flex;
flex-direction: column;
gap: 12rpx;
@@ -364,24 +482,20 @@ function goBack() {
.extra-row {
height: 88rpx;
background: #FFFFFF;
border-radius: 20rpx;
border: 2rpx solid #F0E0D6;
padding: 0 24rpx;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
padding: 0 $space-md;
display: flex;
align-items: center;
gap: 12rpx;
}
.extra-text { font-size: 28rpx; color: #2D1B1B; flex: 1; }
.extra-input { font-size: 28rpx; color: #2D1B1B; flex: 1; }
.extra-text { font-size: $font-lg; color: $text; flex: 1; }
.extra-input { font-size: $font-lg; color: $text; flex: 1; }
.placeholder { color: $text-muted; }
.placeholder { color: #BFB3B3; }
@media (prefers-reduced-motion: reduce) {
.cursor {
animation: none;
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,257 @@
<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="40" color="#2D1B1B" />
</view>
<text class="nav-title">系统配置</text>
<view class="nav-save" @tap="saveConfig">
<text class="save-text">保存</text>
</view>
</view>
</view>
<view class="content" v-if="!loading">
<view class="config-card">
<text class="card-title">应用信息</text>
<view class="config-item">
<text class="config-label">版本号</text>
<input class="config-input" v-model="form.app_version" placeholder="如 1.0.0" />
</view>
<view class="config-divider"></view>
<view class="config-item">
<text class="config-label">标语</text>
<input class="config-input" v-model="form.app_slogan" placeholder="温暖 x 轻松的个人记账小程序" />
</view>
<view class="config-divider"></view>
<view class="config-item">
<text class="config-label">描述</text>
<input class="config-input" v-model="form.app_description" placeholder="让记账从负担变成享受" />
</view>
</view>
<view class="tips-card">
<text class="tips-title">说明</text>
<text class="tips-text"> 版本号会显示在关于小菜弹窗中</text>
<text class="tips-text"> 标语和描述会显示在关于小菜弹窗中</text>
</view>
</view>
<view v-else class="loading-box">
<text class="loading-text">加载中...</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { getConfig, updateConfig } from '@/api/config'
import Icon from '@/components/Icon/Icon.vue'
const loading = ref(true)
const saving = ref(false)
const initialLoaded = ref(false)
const form = reactive({
app_version: '',
app_slogan: '',
app_description: ''
})
onMounted(async () => {
await waitForReady()
try {
await loadConfig()
} catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
initialLoaded.value = true
}
})
// 加载配置数据
async function loadConfig() {
const config = await getConfig()
form.app_version = config.app_version || '1.0.0'
form.app_slogan = config.app_slogan || ''
form.app_description = config.app_description || ''
}
// 返回页面时静默刷新
onShow(() => {
if (initialLoaded.value) loadConfig()
})
onPullDownRefresh(async () => {
await loadConfig()
uni.stopPullDownRefresh()
})
async function saveConfig() {
if (saving.value) return
if (!form.app_version.trim()) {
uni.showToast({ title: '请输入版本号', icon: 'none' })
return
}
saving.value = true
try {
await updateConfig({
app_version: form.app_version.trim(),
app_slogan: form.app_slogan.trim(),
app_description: form.app_description.trim()
})
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch (e: any) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
function goBack() {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: $bg;
}
.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-save {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
}
.save-text {
font-size: $font-lg;
font-weight: 600;
color: $primary;
}
.content {
padding: $space-lg $space-xl;
}
.config-card {
background: $surface;
border-radius: $radius-2xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
overflow: hidden;
margin-bottom: $space-lg;
}
.card-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
display: block;
padding: $space-lg $space-xl $space-sm;
}
.config-item {
display: flex;
align-items: center;
padding: $space-lg $space-xl;
}
.config-label {
width: 120rpx;
font-size: $font-lg;
font-weight: 500;
color: $text;
flex-shrink: 0;
}
.config-input {
flex: 1;
font-size: $font-lg;
color: $text;
}
.config-divider {
height: 2rpx;
background: $border;
margin: 0 $space-xl;
}
.tips-card {
padding: $space-lg $space-xl;
background: $surface;
border-radius: $radius-2xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
}
.tips-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-sm;
}
.tips-text {
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: $space-xs;
}
.loading-box {
display: flex;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.loading-text {
font-size: $font-lg;
color: $text-muted;
}
</style>

View File

@@ -0,0 +1,615 @@
<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="40" color="#2D1B1B" />
</view>
<text class="nav-title">反馈管理</text>
<view class="nav-placeholder"></view>
</view>
</view>
<!-- 状态筛选 -->
<view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: filterStatus === '' }" @tap="switchStatus('')">全部</view>
<view class="tab" :class="{ active: filterStatus === 'pending' }" @tap="switchStatus('pending')">待处理</view>
<view class="tab" :class="{ active: filterStatus === 'processed' }" @tap="switchStatus('processed')">已处理</view>
<view class="tab" :class="{ active: filterStatus === 'ignored' }" @tap="switchStatus('ignored')">已忽略</view>
</view>
</view>
<!-- 列表 -->
<scroll-view class="list" scroll-y @scrolltolower="loadMore">
<view v-for="item in list" :key="item.id" class="feedback-card">
<view class="card-header">
<view class="user-info">
<image v-if="item.avatar_url" class="avatar" :src="getAvatarUrl(item.avatar_url)" mode="aspectFill" />
<view v-else class="avatar-placeholder">
<Icon name="user" :size="24" color="#BFB3B3" />
</view>
<text class="nickname">{{ item.nickname || '用户' + item.user_id }}</text>
</view>
<view class="status-badge" :class="item.status">
<text class="status-text">{{ statusMap[item.status] }}</text>
</view>
</view>
<view class="type-tag">
<text class="type-text">{{ typeMap[item.type] }}</text>
</view>
<text class="content">{{ item.content }}</text>
<view v-if="item.contact" class="contact-row">
<text class="contact-label">联系方式</text>
<text class="contact-value" @tap="copyContact(item.contact)">{{ item.contact }}</text>
</view>
<text class="time">{{ formatTime(item.created_at) }}</text>
<!-- 操作按钮 -->
<view class="actions">
<view v-if="item.status === 'pending'" class="action-btn process" @tap.stop="openReplyModal(item)">
<text class="action-text">标记处理</text>
</view>
<view v-if="item.status === 'pending'" class="action-btn ignore" @tap.stop="openReplyModal(item, 'ignored')">
<text class="action-text">忽略</text>
</view>
<view v-if="item.status !== 'pending'" class="action-btn pending" @tap.stop="updateStatus(item, 'pending')">
<text class="action-text">重新打开</text>
</view>
</view>
</view>
<view v-if="loading" class="loading-box">
<text class="loading-text">加载中...</text>
</view>
<view v-if="noMore && list.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
<view v-if="!loading && list.length === 0" class="empty-box">
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="empty-text">暂无反馈</text>
</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>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback'
import type { Feedback } from '@/api/feedback'
import { API_BASE } from '@/config'
import Icon from '@/components/Icon/Icon.vue'
const typeMap: Record<string, string> = {
bug: '功能异常',
feature: '功能建议',
ux: '体验问题',
other: '其他'
}
const statusMap: Record<string, string> = {
pending: '待处理',
processed: '已处理',
ignored: '已忽略'
}
const list = ref<Feedback[]>([])
const loading = ref(false)
const filterStatus = ref('')
const page = ref(1)
const pageSize = 20
const total = ref(0)
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)
onMounted(async () => {
await waitForReady()
loadList(true).finally(() => { initialLoaded.value = true })
})
onShow(() => {
if (initialLoaded.value) loadList(true)
})
onPullDownRefresh(async () => {
await loadList(true)
uni.stopPullDownRefresh()
})
function switchStatus(status: string) {
filterStatus.value = status
loadList(true)
}
async function loadList(reset = false) {
if (reset) {
page.value = 1
list.value = []
}
loading.value = true
try {
const params: { page: number; pageSize: number; status?: string } = { page: page.value, pageSize }
if (filterStatus.value) params.status = filterStatus.value
const data = await getFeedbackList(params)
if (reset) {
list.value = data.list
} else {
list.value = [...list.value, ...data.list]
}
total.value = data.total
} catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function loadMore() {
if (noMore.value || loading.value) return
page.value++
loadList(false)
}
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 {
await updateFeedbackStatus(item.id, status)
item.status = status
uni.showToast({ title: '已更新', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
function copyContact(contact: string) {
uni.setClipboardData({
data: contact,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
})
}
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 {
const d = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function goBack() { uni.navigateBack() }
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: $bg;
}
.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-placeholder { width: 88rpx; }
.tabs-wrap {
@include tabs-wrap;
margin: 0 0 $space-md;
}
.tabs {
@include tabs;
}
.tab {
@include tab;
font-size: $font-md;
padding: $space-sm $space-md;
}
.list {
height: calc(100vh - 300rpx);
padding: 0 $space-xl;
}
.feedback-card {
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
padding: $space-lg;
margin-bottom: $space-md;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $space-sm;
}
.user-info {
display: flex;
align-items: center;
gap: $space-sm;
}
.avatar {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: $border;
}
.avatar-placeholder {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: $border;
display: flex;
align-items: center;
justify-content: center;
}
.nickname {
font-size: $font-md;
color: $text;
font-weight: 500;
}
.status-badge {
padding: 4rpx 12rpx;
border-radius: $radius-xs;
&.pending { background: #FFF3E0; }
&.processed { background: #E8F5E9; }
&.ignored { background: #F5F5F5; }
}
.status-text {
font-size: $font-xs;
.pending & { color: #F57C00; }
.processed & { color: #43A047; }
.ignored & { color: #9E9E9E; }
}
.type-tag {
display: inline-flex;
padding: 2rpx 12rpx;
background: $primary-light;
border-radius: $radius-xs;
margin-bottom: $space-sm;
}
.type-text {
font-size: $font-xs;
color: $primary;
}
.content {
font-size: $font-lg;
color: $text;
line-height: 1.6;
display: block;
margin-bottom: $space-sm;
}
.contact-row {
display: flex;
align-items: center;
margin-bottom: $space-xs;
}
.contact-label {
font-size: $font-sm;
color: $text-muted;
}
.contact-value {
font-size: $font-sm;
color: $primary;
text-decoration: underline;
}
.time {
font-size: $font-sm;
color: $text-muted;
display: block;
margin-bottom: $space-md;
}
.actions {
display: flex;
gap: $space-sm;
padding-top: $space-md;
border-top: 2rpx solid $border;
}
.action-btn {
padding: $space-xs $space-lg;
border-radius: $radius-lg;
border: 2rpx solid $border;
&.process {
background: $primary-light;
border-color: $primary;
}
&.ignore {
background: $bg;
}
&.pending {
background: $bg;
}
&:active { opacity: 0.7; }
}
.action-text {
font-size: $font-md;
.process & { color: $primary; font-weight: 500; }
.ignore & { color: $text-sec; }
.pending & { color: $text-sec; }
}
.loading-box, .empty-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $space-sm;
padding: 80rpx 0;
}
.loading-text, .empty-text {
font-size: $font-lg;
color: $text-muted;
}
.no-more {
text-align: center;
padding: $space-lg 0;
}
.no-more-text {
font-size: $font-md;
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>

View File

@@ -42,16 +42,20 @@
<view class="detail-card">
<text class="detail-title">本月概况</text>
<view class="detail-row">
<text class="detail-label">交易总额</text>
<text class="detail-value">{{ formatAmount(dashboard.monthlyTxAmount) }}</text>
<text class="detail-label">支出总额</text>
<text class="detail-value expense">{{ formatAmount(dashboard.monthlyExpense || 0) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">收入总额</text>
<text class="detail-value income">{{ formatAmount(dashboard.monthlyIncome || 0) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">月活用户</text>
<text class="detail-value">{{ dashboard.monthlyActive }}</text>
</view>
<view class="detail-row">
<text class="detail-label">上月交易</text>
<text class="detail-value">{{ dashboard.lastMonthTxCount }} / {{ formatAmount(dashboard.lastMonthTxAmount) }}</text>
<text class="detail-label">上月支出</text>
<text class="detail-value">{{ formatAmount(dashboard.lastMonthExpense || 0) }}</text>
</view>
</view>
@@ -63,78 +67,30 @@
<text class="entry-text">用户管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="entry-item" @tap="goNotifications">
<view class="entry-item" @tap="goNotificationManage">
<Icon name="bell" :size="32" color="#FF8C69" />
<text class="entry-text">发布公告</text>
<text class="entry-text">公告管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="entry-item" @tap="goFeedbackManage">
<Icon name="edit" :size="32" color="#FF8C69" />
<text class="entry-text">反馈管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="entry-item" @tap="goConfig">
<Icon name="settings" :size="32" color="#FF8C69" />
<text class="entry-text">系统配置</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<!-- 发布公告弹窗 -->
<view class="modal-mask" v-if="showPublishModal" @tap="showPublishModal = false">
<view class="publish-modal" @tap.stop>
<view class="modal-header">
<text class="modal-title">发布公告</text>
<view class="modal-close" @tap="showPublishModal = false">
<text class="modal-close-text">×</text>
<view class="entry-item" @tap="goLogs">
<Icon name="edit" :size="32" color="#FF8C69" />
<text class="entry-text">系统日志</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<scroll-view class="modal-body" scroll-y>
<view class="form-group">
<text class="form-label">标题 *</text>
<input class="form-input" v-model="publishForm.title" placeholder="输入公告标题" maxlength="100" />
</view>
<view class="form-group">
<text class="form-label">内容</text>
<textarea class="form-textarea" v-model="publishForm.content" placeholder="输入公告内容" maxlength="2000" />
</view>
<view class="form-group">
<text class="form-label">链接可选</text>
<input class="form-input" v-model="publishForm.link_url" placeholder="https://..." />
</view>
<view class="form-group">
<text class="form-label">配图可选</text>
<view v-if="publishForm.image_url" class="image-preview">
<image class="preview-img" :src="publishForm.image_url" mode="aspectFill" />
<view class="image-remove" @tap="publishForm.image_url = ''">
<text class="image-remove-text">×</text>
</view>
</view>
<view v-else class="image-upload" @tap="chooseImage">
<Icon name="plus" :size="40" color="#BFB3B3" />
<text class="upload-text">选择图片</text>
</view>
</view>
<view class="form-group">
<text class="form-label">定时发布可选</text>
<picker mode="date" :value="publishForm.publish_at" @change="e => publishForm.publish_at = e.detail.value">
<view class="form-input picker-input">
<text :class="{ placeholder: !publishForm.publish_at }">{{ publishForm.publish_at || '立即发布' }}</text>
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">过期时间可选</text>
<picker mode="date" :value="publishForm.expire_at" @change="e => publishForm.expire_at = e.detail.value">
<view class="form-input picker-input">
<text :class="{ placeholder: !publishForm.expire_at }">{{ publishForm.expire_at || '永不过期' }}</text>
</view>
</picker>
</view>
<view class="form-row">
<view class="switch-item" @tap="publishForm.is_pinned = !publishForm.is_pinned">
<text class="switch-label">📌 置顶</text>
<view class="switch-toggle" :class="{ on: publishForm.is_pinned }"></view>
</view>
<view class="switch-item" @tap="publishForm.is_urgent = !publishForm.is_urgent">
<text class="switch-label">🔔 强提醒</text>
<view class="switch-toggle" :class="{ on: publishForm.is_urgent }"></view>
</view>
</view>
</scroll-view>
<view class="modal-footer" @tap="handlePublish">
<text class="publish-btn-text">发布</text>
<view class="entry-item" @tap="goTrack">
<Icon name="edit" :size="32" color="#FF8C69" />
<text class="entry-text">埋点统计</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
</view>
@@ -143,10 +99,10 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { getDashboard } from '@/api/admin'
import { publishNotification } from '@/api/notification'
import { formatAmount } from '@/utils/format'
import Icon from '@/components/Icon/Icon.vue'
import type { Dashboard } from '@/api/admin'
@@ -156,6 +112,7 @@ const dashboard = ref<Dashboard | null>(null)
onMounted(async () => {
await waitForReady()
try {
dashboard.value = await getDashboard()
} catch (e: any) {
@@ -165,97 +122,61 @@ onMounted(async () => {
}
})
// 静默刷新
async function refreshDashboard() {
try {
dashboard.value = await getDashboard()
} catch {}
}
// 返回页面时静默刷新
onShow(() => {
refreshDashboard()
})
onPullDownRefresh(async () => {
await refreshDashboard()
uni.stopPullDownRefresh()
})
function goBack() { uni.navigateBack() }
function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) }
const showPublishModal = ref(false)
const publishForm = ref({
title: '',
content: '',
is_pinned: false,
is_urgent: false,
link_url: '',
image_url: '',
publish_at: '',
expire_at: ''
})
function goNotifications() {
publishForm.value = { title: '', content: '', is_pinned: false, is_urgent: false, link_url: '', image_url: '', publish_at: '', expire_at: '' }
showPublishModal.value = true
}
/** 选择配图 */
function chooseImage() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
success: (res) => {
publishForm.value.image_url = res.tempFilePaths[0]
}
})
}
async function handlePublish() {
if (!publishForm.value.title.trim()) {
uni.showToast({ title: '请输入标题', icon: 'none' })
return
}
try {
await publishNotification({
title: publishForm.value.title.trim(),
content: publishForm.value.content.trim(),
is_pinned: publishForm.value.is_pinned,
is_urgent: publishForm.value.is_urgent,
link_url: publishForm.value.link_url.trim() || undefined,
image_url: publishForm.value.image_url || undefined,
publish_at: publishForm.value.publish_at || undefined,
expire_at: publishForm.value.expire_at || undefined
})
showPublishModal.value = false
uni.showToast({ title: '已发布', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '发布失败', icon: 'none' })
}
}
function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) }
function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) }
function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
function goLogs() { uni.navigateTo({ url: '/pages/admin/logs' }) }
function goTrack() { uni.navigateTo({ url: '/pages/admin/track' }) }
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
@include page-base;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar { background: #FFF8F0; }
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: 16rpx 40rpx 24rpx;
padding: $space-sm $space-xl $space-md;
}
.nav-back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
@include nav-back;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-size: $font-2xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
}
.nav-placeholder { width: 88rpx; }
@@ -268,60 +189,60 @@ async function handlePublish() {
}
.loading-text {
font-size: 28rpx;
color: #BFB3B3;
font-size: $font-lg;
color: $text-muted;
}
.content { padding: 0 40rpx; }
.content { padding: 0 $space-xl; }
.metric-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
margin-bottom: 32rpx;
gap: $space-sm;
margin-bottom: $space-lg;
}
.metric-card {
background: #FFFFFF;
border-radius: 28rpx;
padding: 28rpx 24rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
background: $surface;
border-radius: $radius-xl;
padding: $font-lg $space-md;
border: 2rpx solid $border;
box-shadow: $shadow-md;
}
.metric-value {
font-family: 'Fredoka', sans-serif;
font-size: 40rpx;
font-size: $font-3xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
display: block;
&.up { color: #7BC67E; }
&.down { color: #FF6B6B; }
&.up { color: $success; }
&.down { color: $danger; }
}
.metric-label {
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
display: block;
margin-top: 8rpx;
margin-top: $space-xs;
}
.detail-card {
background: #FFFFFF;
border-radius: 28rpx;
padding: 32rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin-bottom: 32rpx;
background: $surface;
border-radius: $radius-xl;
padding: $space-lg;
border: 2rpx solid $border;
box-shadow: $shadow-md;
margin-bottom: $space-lg;
}
.detail-title {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
color: #2D1B1B;
color: $text;
display: block;
margin-bottom: 20rpx;
margin-bottom: $space-lg;
}
.detail-row {
@@ -335,262 +256,47 @@ async function handlePublish() {
}
.detail-label {
font-size: 26rpx;
color: #8B7E7E;
font-size: $font-base;
color: $text-sec;
}
.detail-value {
font-family: 'Fredoka', sans-serif;
font-size: 26rpx;
font-size: $font-base;
font-weight: 500;
color: #2D1B1B;
color: $text;
&.expense { color: $danger; }
&.income { color: $success; }
}
.section-title {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
color: #2D1B1B;
margin-bottom: 16rpx;
color: $text;
margin-bottom: $space-sm;
}
.entry-list { margin-bottom: 32rpx; }
.entry-list { margin-bottom: $space-lg; }
.entry-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 28rpx 32rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin-bottom: 16rpx;
gap: $space-lg;
padding: $font-lg $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
margin-bottom: $space-sm;
&:active { background: #FFF8F0; }
&:active { background: $bg; }
}
.entry-text {
flex: 1;
font-size: 28rpx;
font-size: $font-lg;
font-weight: 500;
color: #2D1B1B;
}
/* 发布公告弹窗 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: flex-end;
}
.publish-modal {
width: 100%;
max-height: 85vh;
background: #FFFFFF;
border-radius: 40rpx 40rpx 0 0;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 40rpx 24rpx;
border-bottom: 2rpx solid #F0E0D6;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #2D1B1B;
}
.modal-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #F0E0D6;
&:active { background: #E0D6D0; }
}
.modal-close-text {
font-size: 36rpx;
color: #8B7E7E;
line-height: 1;
}
.modal-body {
flex: 1;
padding: 24rpx 40rpx;
max-height: 60vh;
}
.form-group {
margin-bottom: 24rpx;
}
.form-label {
font-size: 26rpx;
font-weight: 600;
color: #2D1B1B;
display: block;
margin-bottom: 12rpx;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 24rpx;
background: #FFF8F0;
border-radius: 20rpx;
border: 2rpx solid #F0E0D6;
font-size: 28rpx;
color: #2D1B1B;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
height: 200rpx;
padding: 16rpx 24rpx;
background: #FFF8F0;
border-radius: 20rpx;
border: 2rpx solid #F0E0D6;
font-size: 28rpx;
color: #2D1B1B;
box-sizing: border-box;
}
.form-row {
display: flex;
gap: 24rpx;
}
.switch-item {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 20rpx;
background: #FFF8F0;
border-radius: 16rpx;
border: 2rpx solid #F0E0D6;
&:active { background: #F0E0D6; }
}
.switch-label {
font-size: 26rpx;
color: #2D1B1B;
}
.switch-toggle {
width: 44rpx;
height: 24rpx;
border-radius: 12rpx;
background: #D0C4C4;
position: relative;
transition: background 0.2s;
&.on { background: #FF8C69; }
&::after {
content: '';
position: absolute;
top: 2rpx;
left: 2rpx;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #FFFFFF;
transition: transform 0.2s;
}
&.on::after { transform: translateX(20rpx); }
}
.modal-footer {
padding: 24rpx 40rpx 40rpx;
border-top: 2rpx solid #F0E0D6;
}
.publish-btn-text {
display: block;
text-align: center;
font-size: 30rpx;
font-weight: 600;
color: #FFFFFF;
padding: 20rpx;
background: linear-gradient(135deg, #FF8C69, #E67355);
border-radius: 20rpx;
&:active { opacity: 0.8; }
}
.image-upload {
width: 200rpx;
height: 200rpx;
border: 2rpx dashed #D0C4C4;
border-radius: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
&:active { background: #FFF8F0; }
}
.upload-text {
font-size: 24rpx;
color: #BFB3B3;
}
.image-preview {
position: relative;
width: 200rpx;
height: 200rpx;
}
.preview-img {
width: 100%;
height: 100%;
border-radius: 16rpx;
}
.image-remove {
position: absolute;
top: -12rpx;
right: -12rpx;
width: 40rpx;
height: 40rpx;
background: #FF6B6B;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.image-remove-text {
font-size: 28rpx;
color: #FFFFFF;
line-height: 1;
}
.picker-input {
display: flex;
align-items: center;
.placeholder { color: #BFB3B3; }
color: $text;
}
</style>

View File

@@ -0,0 +1,501 @@
<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="40" color="#2D1B1B" />
</view>
<text class="nav-title">系统日志</text>
<view class="nav-placeholder"></view>
</view>
</view>
<!-- 今日统计 -->
<view class="stats-card" v-if="stats">
<text class="card-title">今日概况</text>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-value">{{ stats.today.total }}</text>
<text class="stat-label">总请求</text>
</view>
<view class="stat-item error">
<text class="stat-value">{{ stats.today.errors }}</text>
<text class="stat-label">错误</text>
</view>
<view class="stat-item warn">
<text class="stat-value">{{ stats.today.warns }}</text>
<text class="stat-label">警告</text>
</view>
<view class="stat-item slow">
<text class="stat-value">{{ stats.today.slow }}</text>
<text class="stat-label">慢请求</text>
</view>
</view>
</view>
<!-- 最近7天趋势 -->
<view class="stats-card" v-if="stats && stats.daily.length > 0">
<text class="card-title">最近7天</text>
<view class="daily-list">
<view v-for="day in stats.daily" :key="day.date" class="daily-item" @tap="selectDate(day.date)">
<text class="daily-date">{{ day.date.slice(5) }}</text>
<view class="daily-bar">
<view class="bar-fill" :style="{ width: getBarWidth(day.total) + '%' }"></view>
</view>
<text class="daily-count">{{ day.total }}</text>
<text v-if="day.errors > 0" class="daily-errors">{{ day.errors }} 错误</text>
</view>
</view>
</view>
<!-- 日志查询 -->
<view class="query-section">
<view class="query-header">
<text class="section-title">日志查询</text>
<picker mode="date" :value="selectedDate" @change="onDateChange">
<view class="date-picker">
<Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="date-text">{{ selectedDate }}</text>
</view>
</picker>
</view>
<!-- 筛选条件 -->
<view class="filter-row">
<view class="filter-tabs">
<view class="filter-tab" :class="{ active: filterLevel === 'all' }" @tap="filterLevel = 'all'">全部</view>
<view class="filter-tab" :class="{ active: filterLevel === 'error' }" @tap="filterLevel = 'error'">错误</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>
<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>
<!-- 日志列表 -->
<scroll-view class="log-list" scroll-y @scrolltolower="loadMore">
<view v-for="(line, index) in logLines" :key="index" class="log-line" :class="getLineClass(line)">
<text class="log-text" user-select>{{ line }}</text>
</view>
<view v-if="loading" class="loading-box">
<text class="loading-text">加载中...</text>
</view>
<view v-if="!loading && logLines.length === 0" class="empty-box">
<text class="empty-text">暂无日志</text>
</view>
<view v-if="noMore && logLines.length > 0" class="no-more">
<text class="no-more-text"> {{ total }} </text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { getLogStats, queryLogs } from '@/api/logs'
import Icon from '@/components/Icon/Icon.vue'
import type { LogStats } from '@/api/logs'
const stats = ref<LogStats | null>(null)
const selectedDate = ref(getTodayStr())
const filterLevel = ref('all')
const keyword = ref('')
const logLines = ref<string[]>([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const noMore = computed(() => logLines.value.length >= total.value && total.value > 0)
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 () => {
await waitForReady()
await Promise.all([loadStats(), loadLogs()])
})
onShow(() => {
loadStats()
})
onPullDownRefresh(async () => {
await Promise.all([loadStats(), loadLogs()])
uni.stopPullDownRefresh()
})
async function loadStats() {
try {
stats.value = await getLogStats()
} catch {}
}
async function loadLogs(append = false) {
if (loading.value) return
if (!append) {
page.value = 1
logLines.value = []
}
loading.value = true
try {
const data = await queryLogs({
date: selectedDate.value,
level: filterLevel.value === 'all' ? undefined : filterLevel.value,
keyword: keyword.value || undefined,
page: page.value,
pageSize: 100
})
if (append) {
logLines.value = [...logLines.value, ...data.lines]
} else {
logLines.value = data.lines
}
total.value = data.total
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function loadMore() {
if (noMore.value || loading.value) return
page.value++
loadLogs(true)
}
function selectDate(date: string) {
selectedDate.value = date
// loadLogs() 由 watch([filterLevel, selectedDate]) 统一触发,避免双重请求
}
function onDateChange(e: any) {
selectedDate.value = e.detail.value
// loadLogs() 由 watch 统一触发
}
function getBarWidth(count: number): number {
if (!stats.value) return 0
const max = Math.max(...stats.value.daily.map(d => d.total), 1)
return (count / max) * 100
}
function getLineClass(line: string): string {
if (line.includes(' ERROR ')) return 'error'
if (line.includes(' WARN ')) return 'warn'
return ''
}
// 筛选条件变化时重新加载
watch([filterLevel, selectedDate], () => {
page.value = 1
loadLogs()
})
function goBack() {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: $bg;
}
.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-placeholder { width: 88rpx; }
.stats-card {
margin: $space-md $space-xl;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
padding: $space-lg;
}
.card-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-md;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $space-md;
}
.stat-item {
text-align: center;
padding: $space-sm;
border-radius: $radius-lg;
background: $bg;
&.error { background: #FFF0F0; }
&.warn { background: #FFF8E1; }
&.slow { background: #FFF3E0; }
}
.stat-value {
font-family: 'Fredoka', sans-serif;
font-size: $font-2xl;
font-weight: 600;
color: $text;
display: block;
.error & { color: $danger; }
.warn & { color: #F57C00; }
.slow & { color: #FF9800; }
}
.stat-label {
font-size: $font-sm;
color: $text-sec;
display: block;
margin-top: $space-xs;
}
.daily-list {
display: flex;
flex-direction: column;
gap: $space-sm;
}
.daily-item {
display: flex;
align-items: center;
gap: $space-sm;
padding: $space-xs 0;
&:active { opacity: 0.7; }
}
.daily-date {
width: 80rpx;
font-size: $font-md;
color: $text-sec;
}
.daily-bar {
flex: 1;
height: 16rpx;
background: $border;
border-radius: 8rpx;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, $primary, #FFB89A);
border-radius: 8rpx;
}
.daily-count {
width: 80rpx;
text-align: right;
font-family: 'Fredoka', sans-serif;
font-size: $font-md;
color: $text;
}
.daily-errors {
font-size: $font-xs;
color: $danger;
width: 100rpx;
}
.query-section {
margin: $space-md $space-xl;
}
.query-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $space-md;
}
.section-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
}
.date-picker {
display: flex;
align-items: center;
gap: $space-xs;
padding: $space-xs $space-md;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
&:active { background: $bg; }
}
.date-text {
font-size: $font-md;
color: $text;
}
.filter-row {
display: flex;
gap: $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 {
display: flex;
gap: $space-xs;
background: $surface-warm;
border-radius: $radius-lg;
padding: $space-xs;
}
.filter-tab {
padding: $space-xs $space-md;
border-radius: $radius-md;
font-size: $font-md;
color: $text-sec;
&.active {
background: $surface;
color: $primary;
font-weight: 500;
}
}
.log-list {
height: calc(100vh - 700rpx);
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
padding: $space-sm;
}
.log-line {
padding: $space-xs $space-sm;
border-bottom: 1rpx solid $border;
&:last-child { border-bottom: none; }
&.error {
background: #FFF5F5;
border-left: 4rpx solid $danger;
}
&.warn {
background: #FFFBF0;
border-left: 4rpx solid #F57C00;
}
}
.log-text {
font-size: $font-sm;
font-family: 'Courier New', monospace;
color: $text;
word-break: break-all;
line-height: 1.6;
}
.loading-box, .empty-box {
display: flex;
align-items: center;
justify-content: center;
padding: $space-xl 0;
}
.loading-text, .empty-text {
font-size: $font-md;
color: $text-muted;
}
.no-more {
text-align: center;
padding: $space-md 0;
}
.no-more-text {
font-size: $font-sm;
color: $text-muted;
}
@media (prefers-reduced-motion: reduce) {
.bar-fill { transition: none; }
}
</style>

View File

@@ -0,0 +1,814 @@
<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="40" color="#2D1B1B" />
</view>
<text class="nav-title">公告管理</text>
<view class="nav-action" @tap="openPublishModal">
<Icon name="plus" :size="40" color="#FF8C69" />
</view>
</view>
</view>
<!-- 公告列表 -->
<view v-if="loading && list.length === 0" class="loading-box">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="list.length === 0" class="empty-box">
<Icon name="bell" :size="48" color="#BFB3B3" />
<text class="empty-text">暂无公告</text>
<view class="empty-btn" @tap="openPublishModal">
<text class="empty-btn-text">发布公告</text>
</view>
</view>
<view v-else class="notification-list">
<view v-for="item in list" :key="item.id" class="notification-item" :class="{ pinned: item.is_pinned }">
<view class="notif-header">
<view class="notif-tags">
<text v-if="item.is_pinned" class="tag pin">置顶</text>
<text v-if="item.is_urgent" class="tag urgent">强提醒</text>
<text class="tag type">{{ getTypeName(item.type) }}</text>
</view>
<text class="notif-time">{{ formatTime(item.created_at) }}</text>
</view>
<text class="notif-title">{{ item.title }}</text>
<text class="notif-content" v-if="item.content">{{ item.content }}</text>
<image v-if="item.image_url" class="notif-image" :src="getNotificationImageUrl(item.image_url)" mode="widthFix" />
<view class="notif-footer">
<view class="notif-actions">
<view class="action-btn" @tap="handleTogglePin(item)">
<Icon name="check" :size="28" :color="item.is_pinned ? '#FF8C69' : '#8B7E7E'" />
<text class="action-text">{{ item.is_pinned ? '取消置顶' : '置顶' }}</text>
</view>
<view class="action-btn" @tap="openEditModal(item)">
<Icon name="edit" :size="28" color="#8B7E7E" />
<text class="action-text">编辑</text>
</view>
<view class="action-btn danger" @tap="handleDelete(item)">
<Icon name="trash" :size="28" color="#FF6B6B" />
<text class="action-text danger">删除</text>
</view>
</view>
</view>
</view>
<view v-if="noMore && list.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</view>
<!-- 发布/编辑公告弹窗 -->
<view class="modal-mask" v-if="showModal" @tap="showModal = false">
<view class="publish-modal" @tap.stop>
<view class="modal-header">
<text class="modal-title">{{ isEditing ? '编辑公告' : '发布公告' }}</text>
<view class="modal-close" @tap="showModal = false">
<text class="modal-close-text">×</text>
</view>
</view>
<scroll-view class="modal-body" scroll-y>
<view class="form-group">
<text class="form-label">标题 *</text>
<input class="form-input" v-model="form.title" placeholder="输入公告标题" maxlength="100" />
</view>
<view class="form-group">
<text class="form-label">内容</text>
<view class="editor-toolbar">
<view class="toolbar-btn" @tap="insertFormat('**', '**')">
<text class="toolbar-text bold">B</text>
</view>
<view class="toolbar-btn" @tap="insertFormat('*', '*')">
<text class="toolbar-text italic">I</text>
</view>
<view class="toolbar-btn" @tap="insertFormat('~~', '~~')">
<text class="toolbar-text strike">S</text>
</view>
<view class="toolbar-btn" @tap="insertFormat('\n- ', '')">
<text class="toolbar-text"></text>
</view>
<view class="toolbar-btn" @tap="insertFormat('\n> ', '')">
<text class="toolbar-text">"</text>
</view>
</view>
<textarea
class="form-textarea rich"
v-model="form.content"
placeholder="输入公告内容支持 Markdown 格式"
maxlength="5000"
:auto-height="false"
ref="contentEditor"
/>
<text class="form-hint">支持 **粗体**、*斜体*、~~删除线~~、列表、引用等格式</text>
</view>
<view class="form-group">
<text class="form-label">链接(可选)</text>
<input class="form-input" v-model="form.link_url" placeholder="https://..." />
</view>
<view class="form-group">
<text class="form-label">配图可选</text>
<view v-if="form.image_url" class="image-preview">
<image class="preview-img" :src="getPreviewImageSrc(form.image_url)" mode="aspectFill" />
<view class="image-remove" @tap="form.image_url = ''">
<text class="image-remove-text">×</text>
</view>
</view>
<view v-else class="image-upload" @tap="chooseImage">
<Icon name="plus" :size="40" color="#BFB3B3" />
<text class="upload-text">选择图片</text>
</view>
</view>
<view class="form-group">
<text class="form-label">定时发布可选</text>
<picker mode="date" :value="form.publish_at" @change="e => form.publish_at = e.detail.value">
<view class="form-input picker-input">
<text :class="{ placeholder: !form.publish_at }">{{ form.publish_at || '立即发布' }}</text>
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">过期时间可选</text>
<picker mode="date" :value="form.expire_at" @change="e => form.expire_at = e.detail.value">
<view class="form-input picker-input">
<text :class="{ placeholder: !form.expire_at }">{{ form.expire_at || '永不过期' }}</text>
</view>
</picker>
</view>
<view class="form-row">
<view class="switch-item" @tap="form.is_pinned = !form.is_pinned">
<text class="switch-label">置顶</text>
<view class="switch-toggle" :class="{ on: form.is_pinned }"></view>
</view>
<view class="switch-item" @tap="form.is_urgent = !form.is_urgent">
<text class="switch-label">强提醒</text>
<view class="switch-toggle" :class="{ on: form.is_urgent }"></view>
</view>
</view>
</scroll-view>
<view class="modal-footer" @tap="handleSubmit">
<text class="submit-btn-text">{{ isEditing ? '保存修改' : '发布' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import {
getNotifications,
publishNotification,
updateNotification,
deleteNotification,
uploadNotificationImage,
getNotificationImageUrl
} from '@/api/notification'
import Icon from '@/components/Icon/Icon.vue'
import type { Notification } from '@/api/notification'
const list = ref<Notification[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const showModal = ref(false)
const isEditing = ref(false)
const editingId = ref<number | null>(null)
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
const defaultForm = {
title: '',
content: '',
is_pinned: false,
is_urgent: false,
link_url: '',
image_url: '',
publish_at: '',
expire_at: ''
}
const form = ref({ ...defaultForm })
onMounted(async () => {
await waitForReady()
await loadNotifications(true)
})
function goBack() {
uni.navigateBack()
}
function getTypeName(type: string) {
const map: Record<string, string> = {
system: '系统',
group: '群组',
personal: '个人'
}
return map[type] || type
}
function formatTime(dateStr: string) {
const d = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}小时前`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}天前`
return `${d.getMonth() + 1}${d.getDate()}`
}
async function loadNotifications(reset = false) {
if (reset) {
page.value = 1
list.value = []
}
loading.value = true
try {
const data = await getNotifications({ page: page.value, pageSize: 20 })
if (reset) {
list.value = data.list
} else {
list.value = [...list.value, ...data.list]
}
total.value = data.total
} catch (e) {
console.error('Load notifications error:', e)
} finally {
loading.value = false
}
}
function openPublishModal() {
isEditing.value = false
editingId.value = null
form.value = { ...defaultForm }
showModal.value = true
}
function openEditModal(item: Notification) {
isEditing.value = true
editingId.value = item.id
form.value = {
title: item.title,
content: item.content || '',
is_pinned: !!item.is_pinned,
is_urgent: !!item.is_urgent,
link_url: item.link_url || '',
image_url: item.image_url || '',
publish_at: item.publish_at || '',
expire_at: item.expire_at || ''
}
showModal.value = true
}
/** 选择配图 */
function chooseImage() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
success: (res) => {
form.value.image_url = res.tempFilePaths[0]
}
})
}
/** 获取预览图片地址 */
function getPreviewImageSrc(url: string) {
if (!url) return ''
if (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('/')) return url
return getNotificationImageUrl(url)
}
/** 插入格式化标记 */
function insertFormat(prefix: string, suffix: string) {
// 简化实现:直接在内容末尾添加
form.value.content += prefix + '文本' + suffix
}
/** 上传图片 */
async function uploadImageIfNeeded(imageUrl: string): Promise<string> {
if (!imageUrl) return ''
// 已经是服务器文件名notif_ 开头)、完整 URL 或 blob URL直接返回
if (imageUrl.startsWith('notif_') || imageUrl.startsWith('http') || imageUrl.startsWith('blob:')) {
return imageUrl
}
// 临时路径需要上传
uni.showLoading({ title: '上传图片中...' })
try {
const result = await uploadNotificationImage(imageUrl)
return result.image_url
} finally {
uni.hideLoading()
}
}
async function handleSubmit() {
if (!form.value.title.trim()) {
uni.showToast({ title: '请输入标题', icon: 'none' })
return
}
try {
const imageUrl = await uploadImageIfNeeded(form.value.image_url)
const data = {
title: form.value.title.trim(),
content: form.value.content.trim(),
is_pinned: form.value.is_pinned,
is_urgent: form.value.is_urgent,
link_url: form.value.link_url.trim() || undefined,
image_url: imageUrl || undefined,
publish_at: form.value.publish_at || undefined,
expire_at: form.value.expire_at || undefined
}
if (isEditing.value && editingId.value) {
await updateNotification(editingId.value, data)
uni.showToast({ title: '已更新', icon: 'success' })
} else {
await publishNotification(data)
uni.showToast({ title: '已发布', icon: 'success' })
}
showModal.value = false
await loadNotifications(true)
} catch (e: any) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
async function handleTogglePin(item: Notification) {
try {
await updateNotification(item.id, { is_pinned: !item.is_pinned })
item.is_pinned = item.is_pinned ? 0 : 1
uni.showToast({ title: item.is_pinned ? '已置顶' : '已取消置顶', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
function handleDelete(item: Notification) {
uni.showModal({
title: '删除公告',
content: `确定删除「${item.title}」?`,
success: async (res) => {
if (res.confirm) {
try {
await deleteNotification(item.id)
list.value = list.value.filter(n => n.id !== item.id)
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
onPullDownRefresh(async () => {
await loadNotifications(true)
uni.stopPullDownRefresh()
})
onReachBottom(() => {
if (noMore.value || loading.value) return
page.value++
loadNotifications(false)
})
</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-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; }
}
.loading-box, .empty-box {
display: flex;
flex-direction: column;
align-items: center;
gap: $space-sm;
padding: 120rpx 0;
}
.loading-text, .empty-text {
font-size: $font-lg;
color: $text-muted;
}
.empty-btn {
margin-top: $space-lg;
padding: $space-sm $space-xl;
background: $primary;
border-radius: $radius-lg;
&:active { opacity: 0.8; }
}
.empty-btn-text {
font-size: $font-base;
color: $surface;
font-weight: 600;
}
.notification-list {
padding: 0 $space-xl;
}
.notification-item {
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
padding: $space-lg;
margin-bottom: $space-sm;
&.pinned {
border-color: $primary;
background: #FFFAF7;
}
}
.notif-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $space-sm;
}
.notif-tags {
display: flex;
gap: $space-xs;
}
.tag {
font-size: $font-xs;
padding: 4rpx 12rpx;
border-radius: $radius-sm;
&.pin {
background: #FFE8E0;
color: $primary;
}
&.urgent {
background: #FFF0E6;
color: #E67355;
}
&.type {
background: $bg;
color: $text-sec;
}
}
.notif-time {
font-size: $font-xs;
color: $text-muted;
}
.notif-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-xs;
}
.notif-content {
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: $space-sm;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.notif-image {
width: 100%;
border-radius: $radius-md;
margin-bottom: $space-sm;
}
.notif-footer {
border-top: 2rpx solid $border;
padding-top: $space-sm;
}
.notif-actions {
display: flex;
justify-content: flex-end;
gap: $space-lg;
}
.action-btn {
display: flex;
align-items: center;
gap: $space-xs;
padding: $space-xs $space-sm;
border-radius: $radius-md;
&:active { background: $bg; }
&.danger {
.action-text.danger { color: $danger; }
}
}
.action-text {
font-size: $font-sm;
color: $text-sec;
}
.no-more {
text-align: center;
padding: $space-lg 0;
}
.no-more-text {
font-size: $font-md;
color: $text-muted;
}
/* 弹窗样式 */
.modal-mask {
@include modal-mask;
}
.publish-modal {
width: 100%;
max-height: 90vh;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
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: 65vh;
}
.form-group {
margin-bottom: $space-md;
}
.form-label {
font-size: $font-base;
font-weight: 600;
color: $text;
display: block;
margin-bottom: 12rpx;
}
.form-input {
height: 80rpx;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
}
.editor-toolbar {
display: flex;
gap: $space-xs;
padding: $space-xs;
background: $bg;
border-radius: $radius-lg $radius-lg 0 0;
border: 2rpx solid $border;
border-bottom: none;
}
.toolbar-btn {
width: 56rpx;
height: 56rpx;
@include flex-center;
border-radius: $radius-sm;
&:active { background: $border; }
}
.toolbar-text {
font-size: $font-lg;
color: $text;
&.bold { font-weight: 700; }
&.italic { font-style: italic; }
&.strike { text-decoration: line-through; }
}
.form-textarea {
width: 100%;
height: 200rpx;
padding: $space-sm $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
&.rich {
border-radius: 0 0 $radius-lg $radius-lg;
min-height: 300rpx;
}
}
.form-hint {
font-size: $font-xs;
color: $text-muted;
margin-top: $space-xs;
display: block;
}
.form-row {
display: flex;
gap: $space-md;
}
.switch-item {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-sm $space-lg;
background: $bg;
border-radius: $radius-md;
border: 2rpx solid $border;
&:active { background: $border; }
}
.switch-label {
font-size: $font-base;
color: $text;
}
.switch-toggle {
width: 44rpx;
height: 24rpx;
border-radius: 12rpx;
background: #D0C4C4;
position: relative;
transition: background $transition-normal;
&.on { background: $primary; }
&::after {
content: '';
position: absolute;
top: 2rpx;
left: 2rpx;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: $surface;
transition: transform $transition-normal;
}
&.on::after { transform: translateX(20rpx); }
}
.modal-footer {
padding: $space-md $space-xl $space-xl;
border-top: 2rpx solid $border;
}
.submit-btn-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; }
}
.image-upload {
width: 200rpx;
height: 200rpx;
border: 2rpx dashed #D0C4C4;
border-radius: $radius-md;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $space-xs;
&:active { background: $bg; }
}
.upload-text {
font-size: $font-md;
color: $text-muted;
}
.image-preview {
position: relative;
width: 200rpx;
height: 200rpx;
}
.preview-img {
width: 100%;
height: 100%;
border-radius: $radius-md;
}
.image-remove {
position: absolute;
top: -12rpx;
right: -12rpx;
width: 40rpx;
height: 40rpx;
background: $danger;
border-radius: 50%;
@include flex-center;
}
.image-remove-text {
font-size: $font-lg;
color: $surface;
line-height: 1;
}
.picker-input {
display: flex;
align-items: center;
.placeholder { color: $text-muted; }
}
</style>

View File

@@ -0,0 +1,659 @@
<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="40" color="#2D1B1B" />
</view>
<text class="nav-title">埋点统计</text>
<view class="nav-placeholder"></view>
</view>
</view>
<!-- 今日事件分布 -->
<view class="stats-card" v-if="stats">
<text class="card-title">今日事件</text>
<view v-if="stats.today.length === 0" class="empty-hint">
<text class="empty-text">暂无数据</text>
</view>
<view v-else class="event-list">
<view v-for="item in stats.today" :key="item.event" class="event-item">
<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>
</view>
</view>
</view>
<!-- 最近7天趋势 -->
<view class="stats-card" v-if="stats && stats.daily.length > 0">
<text class="card-title">最近7天</text>
<view class="daily-list">
<view v-for="day in stats.daily" :key="day.date" class="daily-item">
<text class="daily-date">{{ formatDate(day.date) }}</text>
<view class="daily-bar">
<view class="bar-fill" :style="{ width: getBarWidth(day.count) + '%' }"></view>
</view>
<text class="daily-count">{{ day.count }}</text>
</view>
</view>
</view>
<!-- 热门事件 -->
<view class="stats-card" v-if="stats && stats.events.length > 0">
<text class="card-title">热门事件7</text>
<view class="event-list">
<view v-for="item in stats.events" :key="item.event" class="event-item" @tap="filterByEvent(item.event)">
<view class="event-tag" :class="getEventClass(item.event)">
<text class="event-tag-text">{{ getEventName(item.event) }}</text>
</view>
<view class="event-bar">
<view class="bar-fill" :style="{ width: getEventBarWidth(item.count) + '%' }"></view>
</view>
<text class="event-count">{{ item.count }}</text>
</view>
</view>
</view>
<!-- 事件列表 -->
<view class="query-section">
<view class="query-header">
<text class="section-title">事件记录</text>
<scroll-view scroll-x>
<view class="filter-tabs">
<view class="filter-tab" :class="{ active: !filterEvent }" @tap="filterEvent = ''">全部</view>
<view class="filter-tab" :class="{ active: filterEvent === 'page_view' }" @tap="filterEvent = 'page_view'">
页面</view>
<view class="filter-tab" :class="{ active: filterEvent === 'action' }" @tap="filterEvent = 'action'">操作
</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>
</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">
<view v-for="item in eventList" :key="item.id" class="record-item">
<view class="record-header">
<view class="record-event" :class="getEventClass(item.event)">
<text class="record-event-text">{{ item.event }}</text>
</view>
<text class="record-time">{{ formatTime(item.created_at) }}</text>
</view>
<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-data" v-if="item.data && Object.keys(item.data).length > 0">{{ JSON.stringify(item.data)
}}</text>
</view>
<view v-if="loading" class="loading-box">
<text class="loading-text">加载中...</text>
</view>
<view v-if="!loading && eventList.length === 0" class="empty-box">
<text class="empty-text">暂无记录</text>
</view>
<view v-if="noMore && eventList.length > 0" class="no-more">
<text class="no-more-text"> {{ total }} </text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { getTrackStats, queryTrackEvents } from '@/api/logs'
import Icon from '@/components/Icon/Icon.vue'
import type { TrackStats, TrackEvent } from '@/api/logs'
const stats = ref<TrackStats | null>(null)
const filterEvent = ref('')
const filterDate = ref('')
const filterKeyword = ref('')
const eventList = ref<TrackEvent[]>([])
const loading = ref(false)
const page = ref(1)
const total = ref(0)
const noMore = computed(() => eventList.value.length >= total.value && total.value > 0)
onMounted(async () => {
await waitForReady()
await Promise.all([loadStats(), loadEvents()])
})
onShow(() => {
loadStats()
})
onPullDownRefresh(async () => {
await Promise.all([loadStats(), loadEvents()])
uni.stopPullDownRefresh()
})
async function loadStats() {
try {
stats.value = await getTrackStats()
} catch { }
}
async function loadEvents(append = false) {
if (loading.value) return
if (!append) {
page.value = 1
eventList.value = []
}
loading.value = true
try {
const data = await queryTrackEvents({
event: filterEvent.value || undefined,
page: page.value,
pageSize: 50
})
if (append) {
eventList.value = [...eventList.value, ...data.list]
} else {
eventList.value = data.list
}
total.value = data.total
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function loadMore() {
if (noMore.value || loading.value) return
page.value++
loadEvents(true)
}
function filterByEvent(event: string) {
filterEvent.value = event
}
function onDateChange(e: any) {
filterDate.value = e.detail.value
loadEvents()
}
function getBarWidth(count: number): number {
if (!stats.value || stats.value.daily.length === 0) return 0
const max = Math.max(...stats.value.daily.map(d => d.count), 1)
return (count / max) * 100
}
function getEventBarWidth(count: number): number {
if (!stats.value || stats.value.events.length === 0) return 0
const max = Math.max(...stats.value.events.map(e => e.count), 1)
return (count / max) * 100
}
function getEventClass(event: string): string {
if (event === 'error' || event === 'api_error') return 'error'
if (event === 'action') return 'action'
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 格式) */
function formatDate(dateStr: string): string {
if (!dateStr) return ''
// 处理 2026-06-08T00:00:00.000Z 格式
const datePart = dateStr.split('T')[0]
const parts = datePart.split('-')
if (parts.length === 3) {
return `${parseInt(parts[1])}/${parseInt(parts[2])}`
}
return dateStr
}
function formatTime(dateStr: string): string {
const d = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}小时前`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}天前`
return `${d.getMonth() + 1}/${d.getDate()}`
}
// 筛选条件变化时重新加载
watch(filterEvent, () => {
page.value = 1
loadEvents()
})
function goBack() {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: $bg;
}
.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-placeholder {
width: 88rpx;
}
.stats-card {
margin: $space-md $space-xl;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
padding: $space-lg;
}
.card-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-md;
}
.empty-hint {
padding: $space-lg 0;
text-align: center;
}
.empty-text {
font-size: $font-md;
color: $text-muted;
}
.event-list {
display: flex;
flex-direction: column;
gap: $space-sm;
}
.event-item {
display: flex;
align-items: center;
gap: $space-sm;
padding: $space-xs 0;
&:active {
opacity: 0.7;
}
}
.event-tag {
padding: 2rpx 12rpx;
border-radius: $radius-xs;
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 {
flex: 1;
height: 16rpx;
background: $border;
border-radius: 8rpx;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, $primary, #FFB89A);
border-radius: 8rpx;
}
.event-count {
width: 80rpx;
text-align: right;
font-family: 'Fredoka', sans-serif;
font-size: $font-md;
color: $text;
}
.daily-list {
display: flex;
flex-direction: column;
gap: $space-sm;
}
.daily-item {
display: flex;
align-items: center;
gap: $space-sm;
padding: $space-xs 0;
}
.daily-date {
width: 80rpx;
font-size: $font-md;
color: $text-sec;
}
.daily-bar {
flex: 1;
height: 16rpx;
background: $border;
border-radius: 8rpx;
overflow: hidden;
}
.daily-count {
width: 80rpx;
text-align: right;
font-family: 'Fredoka', sans-serif;
font-size: $font-md;
color: $text;
}
.query-section {
margin: $space-md $space-xl;
}
.query-header {
display: flex;
flex-direction: column;
gap: $space-sm;
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 {
font-size: $font-lg;
font-weight: 600;
color: $text;
}
.filter-tabs {
display: flex;
gap: $space-xs;
white-space: nowrap;
}
.filter-tab {
padding: $space-xs $space-md;
border-radius: $radius-lg;
font-size: $font-md;
color: $text-sec;
background: $surface-warm;
&.active {
background: $primary;
color: $surface;
font-weight: 500;
}
}
.event-record-list {
height: calc(100vh - 800rpx);
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
padding: $space-sm;
}
.record-item {
padding: $space-md;
border-bottom: 1rpx solid $border;
&:last-child {
border-bottom: none;
}
}
.record-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: $space-xs;
}
.record-event {
padding: 2rpx 12rpx;
border-radius: $radius-xs;
&.page {
background: #E3F2FD;
}
&.action {
background: #E8F5E9;
}
&.error {
background: #FFEBEE;
}
}
.record-event-text {
font-size: $font-xs;
font-weight: 500;
.page & {
color: #1976D2;
}
.action & {
color: #388E3C;
}
.error & {
color: #D32F2F;
}
}
.record-time {
font-size: $font-xs;
color: $text-muted;
}
.record-page {
font-size: $font-sm;
color: $text-sec;
display: block;
margin-bottom: $space-xs;
}
.record-user {
font-size: $font-sm;
color: $primary;
display: block;
margin-bottom: $space-xs;
}
.record-data {
font-size: $font-xs;
color: $text-muted;
font-family: 'Courier New', monospace;
word-break: break-all;
display: block;
padding: $space-xs;
background: $bg;
border-radius: $radius-sm;
}
.loading-box,
.empty-box {
display: flex;
align-items: center;
justify-content: center;
padding: $space-xl 0;
}
.loading-text {
font-size: $font-md;
color: $text-muted;
}
.no-more {
text-align: center;
padding: $space-md 0;
}
.no-more-text {
font-size: $font-sm;
color: $text-muted;
}
@media (prefers-reduced-motion: reduce) {
.bar-fill {
transition: none;
}
}
</style>

View File

@@ -54,7 +54,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { API_BASE } from '@/config'
@@ -71,9 +71,22 @@ const page = ref(1)
const total = ref(0)
const loading = ref(false)
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
await loadUsers(true)
initialLoaded.value = true
})
// 返回页面时静默刷新
onShow(() => {
if (initialLoaded.value) loadUsers(true)
})
onPullDownRefresh(async () => {
await loadUsers(true)
uni.stopPullDownRefresh()
})
async function loadUsers(reset = false) {
@@ -83,7 +96,7 @@ async function loadUsers(reset = false) {
}
loading.value = true
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()
const data = await getUsers(params)
if (reset) {
@@ -162,86 +175,77 @@ onReachBottom(() => {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
@include page-base;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar { background: #FFF8F0; }
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: 16rpx 40rpx 24rpx;
padding: $space-sm $space-xl $space-md;
}
.nav-back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
@include nav-back;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-size: $font-2xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
}
.nav-placeholder { width: 88rpx; }
.search-bar {
padding: 0 40rpx 24rpx;
padding: 0 $space-xl $space-md;
}
.search-input {
width: 100%;
height: 72rpx;
padding: 0 24rpx;
background: #FFFFFF;
border-radius: 20rpx;
border: 2rpx solid #F0E0D6;
font-size: 28rpx;
color: #2D1B1B;
padding: 0 $space-md;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
}
.loading-box, .empty-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
gap: $space-sm;
padding: 120rpx 0;
}
.loading-text, .empty-text {
font-size: 28rpx;
color: #BFB3B3;
font-size: $font-lg;
color: $text-muted;
}
.user-list { padding: 0 40rpx; }
.user-list { padding: 0 $space-xl; }
.user-item {
display: flex;
align-items: flex-start;
gap: 20rpx;
padding: 24rpx 28rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin-bottom: 16rpx;
gap: $space-lg;
padding: $space-md $font-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
margin-bottom: $space-sm;
overflow: hidden;
}
@@ -249,7 +253,7 @@ onReachBottom(() => {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #F0E0D6;
background: $border;
flex-shrink: 0;
}
@@ -262,9 +266,9 @@ onReachBottom(() => {
}
.user-name {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
color: #2D1B1B;
color: $text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -272,24 +276,24 @@ onReachBottom(() => {
.role-tag {
padding: 2rpx 12rpx;
border-radius: 8rpx;
border-radius: $radius-xs;
}
.role-tag-text {
font-size: 20rpx;
font-size: $font-xs;
font-weight: 500;
}
.user-meta {
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
display: block;
margin-top: 4rpx;
}
.user-time {
font-size: 22rpx;
color: #BFB3B3;
font-size: $font-sm;
color: $text-muted;
display: block;
margin-top: 4rpx;
}
@@ -297,23 +301,23 @@ onReachBottom(() => {
.user-actions {
display: flex;
flex-direction: column;
gap: 8rpx;
gap: $space-xs;
flex-shrink: 0;
}
.action-btn {
padding: 8rpx 16rpx;
background: #FFF0E6;
border-radius: 12rpx;
padding: $space-xs $space-sm;
background: $surface-warm;
border-radius: $radius-sm;
text-align: center;
&.danger { background: #FFE8E8; }
&.danger { background: $danger-light; }
&:active { opacity: 0.7; }
}
.action-text {
font-size: 22rpx;
color: #FF8C69;
&.danger-text { color: #FF6B6B; }
font-size: $font-sm;
color: $primary;
&.danger-text { color: $danger; }
}
</style>

View File

@@ -213,24 +213,21 @@ function handleReset() {
function handleApply() {
const filters: FilterParams = { ...localFilters };
// 转换金额:元 → 分
filters.minAmount = minAmountStr.value
? Math.round(parseFloat(minAmountStr.value) * 100)
: 0;
filters.maxAmount = maxAmountStr.value
? Math.round(parseFloat(maxAmountStr.value) * 100)
: 0;
// 转换金额:元 → 分,防御 NaN
const minVal = parseFloat(minAmountStr.value);
const maxVal = parseFloat(maxAmountStr.value);
filters.minAmount = minAmountStr.value && !isNaN(minVal) ? Math.round(minVal * 100) : 0;
filters.maxAmount = maxAmountStr.value && !isNaN(maxVal) ? Math.round(maxVal * 100) : 0;
emit("apply", filters);
}
async function handleSave() {
const filters: FilterParams = { ...localFilters };
filters.minAmount = minAmountStr.value
? Math.round(parseFloat(minAmountStr.value) * 100)
: 0;
filters.maxAmount = maxAmountStr.value
? Math.round(parseFloat(maxAmountStr.value) * 100)
: 0;
// 转换金额:元 → 分,防御 NaN
const minVal = parseFloat(minAmountStr.value);
const maxVal = parseFloat(maxAmountStr.value);
filters.minAmount = minAmountStr.value && !isNaN(minVal) ? Math.round(minVal * 100) : 0;
filters.maxAmount = maxAmountStr.value && !isNaN(maxVal) ? Math.round(maxVal * 100) : 0;
uni.showModal({
title: "保存方案",
@@ -271,26 +268,19 @@ async function handleDeleteSaved(id: number) {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.filter-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: flex-end;
@include modal-mask;
}
.filter-panel {
width: 100%;
max-height: 80vh;
background: #ffffff;
border-radius: 40rpx 40rpx 0 0;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
}
@@ -298,75 +288,70 @@ async function handleDeleteSaved(id: number) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 40rpx 24rpx;
border-bottom: 2rpx solid #f0e0d6;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
.filter-title {
font-size: 32rpx;
font-size: $font-xl;
font-weight: 600;
color: #2d1b1b;
color: $text;
}
.filter-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
@include flex-center;
border-radius: 50%;
background: #f0e0d6;
background: $border;
&:active {
background: #e0d6d0;
background: darken($border, 5%);
}
}
.filter-close-text {
font-size: 36rpx;
color: #8b7e7e;
font-size: $font-2xl;
color: $text-sec;
line-height: 1;
}
.filter-body {
flex: 1;
padding: 24rpx 40rpx;
padding: $space-md $space-xl;
max-height: 55vh;
overflow-x: hidden;
box-sizing: border-box;
}
.filter-section {
margin-bottom: 32rpx;
margin-bottom: $space-lg;
width: 100%;
box-sizing: border-box;
}
.section-label {
font-size: 26rpx;
font-size: $font-base;
font-weight: 600;
color: #2d1b1b;
color: $text;
display: block;
margin-bottom: 16rpx;
margin-bottom: $space-sm;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
gap: $space-xs;
width: 100%;
box-sizing: border-box;
}
.chip {
padding: 12rpx 24rpx;
background: #fff8f0;
border-radius: 20rpx;
border: 2rpx solid #f0e0d6;
transition: all 0.2s;
padding: $space-xs $space-md;
background: $bg;
border-radius: $radius-xl;
border: 2rpx solid $border;
transition: all $transition-normal;
&.active {
background: #ffe8e0;
border-color: #ff8c69;
background: $primary-light;
border-color: $primary;
}
&:active {
@@ -375,10 +360,10 @@ async function handleDeleteSaved(id: number) {
}
.chip-text {
font-size: 24rpx;
color: #2d1b1b;
font-size: $font-md;
color: $text;
.active & {
color: #ff8c69;
color: $primary;
font-weight: 600;
}
}
@@ -386,130 +371,123 @@ async function handleDeleteSaved(id: number) {
.date-row {
display: flex;
align-items: center;
gap: 16rpx;
gap: $space-sm;
width: 100%;
box-sizing: border-box;
}
.date-picker {
flex: 1;
height: 72rpx;
padding: 0 20rpx;
background: #fff8f0;
border-radius: 16rpx;
border: 2rpx solid #f0e0d6;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
display: flex;
align-items: center;
}
.date-text {
font-size: 26rpx;
color: #2d1b1b;
font-size: $font-base;
color: $text;
&.placeholder {
color: #bfb3b3;
color: $text-muted;
}
}
.date-sep {
font-size: 26rpx;
color: #8b7e7e;
font-size: $font-base;
color: $text-sec;
}
.amount-row {
display: flex;
align-items: center;
gap: 16rpx;
gap: $space-sm;
width: 100%;
box-sizing: border-box;
}
.amount-input {
flex: 1;
height: 72rpx;
padding: 0 20rpx;
background: #fff8f0;
border-radius: 16rpx;
border: 2rpx solid #f0e0d6;
font-size: 26rpx;
color: #2d1b1b;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-base;
color: $text;
}
.amount-sep {
font-size: 26rpx;
color: #8b7e7e;
font-size: $font-base;
color: $text-sec;
}
.keyword-input {
width: 100%;
height: 72rpx;
padding: 0 20rpx;
background: #fff8f0;
border-radius: 16rpx;
border: 2rpx solid #f0e0d6;
font-size: 26rpx;
color: #2d1b1b;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-base;
color: $text;
}
.saved-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 20rpx;
background: #fff8f0;
border-radius: 16rpx;
margin-bottom: 8rpx;
padding: $space-sm $space-md;
background: $bg;
border-radius: $radius-lg;
margin-bottom: $space-xs;
&:active {
background: #f0e0d6;
background: $border;
}
}
.saved-name {
font-size: 26rpx;
color: #2d1b1b;
font-size: $font-base;
color: $text;
}
.saved-delete {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
@include flex-center;
&:active {
opacity: 0.6;
}
}
.saved-delete-text {
font-size: 28rpx;
color: #bfb3b3;
font-size: $font-lg;
color: $text-muted;
}
.filter-footer {
display: flex;
gap: 16rpx;
padding: 24rpx 40rpx 40rpx;
border-top: 2rpx solid #f0e0d6;
gap: $space-sm;
padding: $space-md $space-xl $space-xl;
border-top: 2rpx solid $border;
}
.footer-btn {
flex: 1;
height: 80rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-xl;
@include flex-center;
&.reset {
background: #fff8f0;
border: 2rpx solid #f0e0d6;
background: $bg;
border: 2rpx solid $border;
}
&.save {
background: #fff0e6;
border: 2rpx solid #ff8c69;
background: $surface-warm;
border: 2rpx solid $primary;
}
&.apply {
background: linear-gradient(135deg, #ff8c69, #e67355);
background: linear-gradient(135deg, $primary, $primary-dark);
}
&:active {
@@ -518,16 +496,16 @@ async function handleDeleteSaved(id: number) {
}
.footer-btn-text {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
&.reset-text {
color: #8b7e7e;
color: $text-sec;
}
&.save-text {
color: #ff8c69;
color: $primary;
}
&.apply-text {
color: #ffffff;
color: $surface;
}
}
</style>

View File

@@ -27,7 +27,7 @@
<!-- 筛选面板 -->
<FilterPanel v-if="showFilter" :initialFilters="advancedFilters" @close="showFilter = false" @apply="onFilterApply" />
<view class="tx-list" v-if="!txStore.loading || txList.length > 0">
<view class="tx-list" v-if="!loading || txList.length > 0">
<view v-for="group in groupedTx" :key="group.date" class="date-group">
<view class="group-header">
<text class="group-date">{{ group.label }}</text>
@@ -49,7 +49,7 @@
</view>
</view>
<view class="tx-list" v-if="txStore.loading && txList.length === 0">
<view class="tx-list" v-if="loading && txList.length === 0">
<view v-for="g in 2" :key="g" class="date-group">
<view class="group-header">
<Skeleton width="120rpx" height="26rpx" variant="text" />
@@ -66,7 +66,7 @@
</view>
</view>
<view class="loading-more" v-if="txStore.loading && txList.length > 0">
<view class="loading-more" v-if="loading && txList.length > 0">
<text class="loading-text">加载中...</text>
</view>
@@ -79,8 +79,9 @@
<text class="state-text">加载失败下拉刷新重试</text>
</view>
<view class="empty" v-if="txList.length === 0 && !txStore.loading && !loadError">
<text class="empty-text">暂无账单记录</text>
<view class="state-box" v-if="txList.length === 0 && !loading && !loadError">
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">暂无账单记录</text>
</view>
</view>
</template>
@@ -88,25 +89,26 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions, deleteTransaction } from '@/api/transaction'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
import FilterPanel from './filter-panel.vue'
import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format'
import type { Transaction, TransactionParams } from '@/api/transaction'
import type { FilterParams } from '@/api/filter'
const txStore = useTransactionStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const loading = ref(false)
const filterType = ref('all')
const page = ref(1)
const pageSize = 20
const txList = ref<any[]>([])
const txList = ref<Transaction[]>([])
const total = ref(0)
const loadError = ref(false)
let loadSeq = 0 // 请求序号,防止并发覆盖
@@ -162,7 +164,7 @@ async function loadTx(reset = false, silent = false) {
if (!silent) txList.value = []
}
const seq = ++loadSeq
const params: any = { page: page.value, pageSize }
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
// 合并高级筛选参数
const af = advancedFilters.value
@@ -173,24 +175,25 @@ async function loadTx(reset = false, silent = false) {
if (af.maxAmount) params.maxAmount = af.maxAmount
if (af.keyword) params.keyword = af.keyword
try {
loading.value = true
loadError.value = false
const data = await txStore.fetchTransactions(params)
const data = await getTransactions(params)
// 丢弃过期请求的结果
if (seq !== loadSeq) return
if (reset) {
txList.value = txStore.transactions
txList.value = data.list
} else {
txList.value = [...txList.value, ...txStore.transactions]
txList.value = [...txList.value, ...data.list]
}
total.value = txStore.total
if (data) {
total.value = data.total
filteredExpense.value = data.filteredExpense || 0
filteredIncome.value = data.filteredIncome || 0
}
} catch (e) {
if (seq !== loadSeq) return
console.error('Load bills error:', e)
loadError.value = true
} finally {
if (seq === loadSeq) loading.value = false
}
}
@@ -219,9 +222,15 @@ async function onDelete(item: any) {
success: async (res) => {
if (res.confirm) {
try {
await txStore.deleteTransaction(item.id)
await deleteTransaction(item.id)
txList.value = txList.value.filter(t => t.id !== item.id)
total.value--
// 更新筛选金额
if (item.type === 'expense') {
filteredExpense.value = Math.max(0, filteredExpense.value - item.amount)
} else {
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
}
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
@@ -233,7 +242,7 @@ async function onDelete(item: any) {
// 触底加载更多
onReachBottom(() => {
if (noMore.value || txStore.loading) return
if (noMore.value || loading.value) return
page.value++
loadTx(false)
})
@@ -245,60 +254,36 @@ onPullDownRefresh(() => {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
@include page-base;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar {
background: #FFF8F0;
@include status-bar;
}
.page-title {
display: block;
text-align: center;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
padding: 16rpx 0 24rpx;
@include page-title;
}
.tabs-wrap {
display: flex;
align-items: center;
@include tabs-wrap;
justify-content: center;
margin: 0 0 24rpx;
gap: 16rpx;
margin: 0 0 $space-md;
}
.tabs {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
@include tabs;
}
.tab {
padding: 16rpx 40rpx;
border-radius: 20rpx;
font-size: 28rpx;
color: #8B7E7E;
transition: all 0.2s;
&.active {
background: #FFFFFF;
color: #FF8C69;
font-weight: 600;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
}
@include tab;
}
.filter-btn {
@@ -306,104 +291,92 @@ onPullDownRefresh(() => {
align-items: center;
gap: 6rpx;
padding: 12rpx 20rpx;
background: #FFF0E6;
border-radius: 20rpx;
border: 2rpx solid #F0E0D6;
background: $surface-warm;
border-radius: $radius-lg;
border: 2rpx solid $border;
&.active {
border-color: #FF8C69;
background: #FFE8E0;
border-color: $primary;
background: $primary-light;
}
&:active { opacity: 0.7; }
}
.filter-btn-text {
font-size: 24rpx;
color: #8B7E7E;
.active & { color: #FF8C69; }
font-size: $font-md;
color: $text-sec;
.active & { color: $primary; }
}
.filter-stats {
display: flex;
gap: 24rpx;
padding: 0 40rpx 16rpx;
gap: $space-md;
padding: 0 $space-xl $space-sm;
}
.filter-stats-text {
font-size: 24rpx;
font-size: $font-md;
font-family: 'Fredoka', sans-serif;
color: #8B7E7E;
color: $text-sec;
}
.tx-list { padding: 0 40rpx; }
.tx-list { padding: 0 $space-xl; }
.date-group {
margin-bottom: 24rpx;
margin-bottom: $space-md;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #F0E0D6;
margin-bottom: 8rpx;
padding: $space-sm 0;
border-bottom: 1rpx solid $border;
margin-bottom: $space-xs;
}
.group-date {
font-size: 26rpx;
font-size: $font-base;
font-weight: 600;
color: #2D1B1B;
color: $text;
}
.group-total {
font-family: 'Fredoka', sans-serif;
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
&.expense { color: #2D1B1B; }
&.expense { color: $text; }
}
.skeleton-item {
display: flex;
align-items: center;
padding: 28rpx 0;
gap: 24rpx;
gap: $space-md;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
gap: $space-xs;
}
.loading-more {
text-align: center;
padding: 32rpx 0;
padding: $space-lg 0;
}
.loading-text { font-size: 24rpx; color: #BFB3B3; }
.empty {
text-align: center;
padding: 120rpx 0;
}
.empty-text { font-size: 28rpx; color: #BFB3B3; }
.loading-text { font-size: $font-md; color: $text-muted; }
.state-box {
display: flex;
flex-direction: column;
align-items: center;
@include state-box;
justify-content: center;
gap: 16rpx;
padding: 120rpx 0;
}
.state-text {
font-size: 28rpx;
color: #BFB3B3;
@include state-text;
}
</style>

View File

@@ -60,42 +60,54 @@
<!-- 自定义金额 -->
<view class="section-title">自定义金额</view>
<view class="custom-input">
<text class="input-prefix">¥</text>
<text class="input-value" :class="{ placeholder: !amountStr }">{{ amountStr }}</text>
<text class="input-cursor">|</text>
<text class="input-unit"></text>
<AmountEditor
ref="amountEditorRef"
v-model="amountStr"
:fixed="true"
size="large"
unit="元"
:auto-focus="false"
@focus="keyboardVisible = true"
@blur="keyboardVisible = false"
@confirm="onConfirm"
/>
</view>
<!-- Numpad -->
<Numpad v-model="amountStr" @confirm="onConfirm" />
<!-- 底部保存按钮键盘收起时显示 -->
<view class="save-bar" v-if="!keyboardVisible">
<view class="save-btn" @tap="onConfirm">
<text class="save-btn-text">保存预算</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useBudgetStore } from '@/stores/budget'
import { useGroupStore } from '@/stores/group'
import { getOverview } from '@/api/stats'
import { waitForReady } from '@/utils/app-ready'
import { formatAmount, getCurrentMonth } from '@/utils/format'
import type { Budget } from '@/api/budget'
import { statusBarHeight } from '@/utils/system'
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'
const budgetStore = useBudgetStore()
const groupStore = useGroupStore()
const currentMonth = getCurrentMonth()
const budget = ref<any>(null)
const budget = ref<Budget | null>(null)
const budgetAmount = ref(0)
const monthExpense = ref(0)
const loading = ref(true)
const initialLoaded = ref(false)
const amountStr = ref('')
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const keyboardVisible = ref(false)
const presets = [1000, 2000, 3000, 5000, 10000]
@@ -150,8 +162,18 @@ onShow(async () => {
} catch {}
})
onPullDownRefresh(async () => {
try {
await budgetStore.fetchBudget(currentMonth)
syncBudgetData()
fetchMyExpense()
} catch {}
uni.stopPullDownRefresh()
})
function selectPreset(val: number) {
amountStr.value = String(val)
amountEditorRef.value?.focus()
}
async function onConfirm() {
@@ -180,46 +202,45 @@ function goBack() {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding-bottom: 120rpx;
background: $bg;
// AmountEditor fixed Numpad 占位 + 保存按钮占位
$numpad-h: 464rpx;
$save-bar-h: 144rpx;
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + env(safe-area-inset-bottom));
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar {
background: #FFF8F0;
@include status-bar;
}
.nav-bar {
display: flex;
align-items: center;
padding: 16rpx 32rpx;
padding: $space-sm $space-lg;
}
.nav-back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
@include nav-back;
border-radius: 50%;
&:active { background: #F0E0D6; }
&:active { background: $border; }
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-size: $font-2xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
}
.nav-placeholder {
@@ -227,18 +248,18 @@ function goBack() {
}
.content {
padding: 0 40rpx;
padding: 0 $space-xl;
}
.overview-row {
display: flex;
align-items: center;
background: #FFFFFF;
border-radius: 28rpx;
padding: 28rpx 32rpx;
margin-bottom: 24rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
background: $surface;
border-radius: $radius-xl;
padding: $font-lg $space-lg;
margin-bottom: $space-md;
border: 2rpx solid $border;
box-shadow: $shadow-md;
}
.overview-item {
@@ -247,182 +268,160 @@ function goBack() {
}
.overview-label {
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: 8rpx;
margin-bottom: $space-xs;
}
.overview-value {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-size: $font-xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
&.highlight { color: #FF8C69; }
&.highlight { color: $primary; }
}
.overview-divider {
width: 2rpx;
height: 56rpx;
background: #F0E0D6;
background: $border;
flex-shrink: 0;
}
.preview-card {
background: #FFFFFF;
border-radius: 40rpx;
padding: 48rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin-bottom: 48rpx;
background: $surface;
border-radius: $radius-2xl;
padding: $space-2xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
margin-bottom: $space-2xl;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
margin-bottom: $space-sm;
}
.preview-label {
font-size: 28rpx;
color: #8B7E7E;
font-size: $font-lg;
color: $text-sec;
}
.preview-month {
font-size: 24rpx;
color: #BFB3B3;
font-size: $font-md;
color: $text-muted;
}
.preview-amount {
font-family: 'Fredoka', sans-serif;
font-size: 56rpx;
font-size: $font-4xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
display: block;
margin-bottom: 24rpx;
margin-bottom: $space-md;
}
.preview-skeleton {
width: 240rpx;
height: 64rpx;
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
background-size: 200% 100%;
border-radius: 12rpx;
margin-bottom: 24rpx;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
@include shimmer;
border-radius: $radius-sm;
margin-bottom: $space-md;
}
.preview-hint {
font-size: 28rpx;
color: #BFB3B3;
font-size: $font-lg;
color: $text-muted;
display: block;
text-align: center;
padding: 16rpx 0;
padding: $space-sm 0;
}
.section-title {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
color: #2D1B1B;
margin-bottom: 24rpx;
color: $text;
margin-bottom: $space-md;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
margin-bottom: 48rpx;
gap: $space-lg;
margin-bottom: $space-2xl;
}
.preset-item {
height: 96rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
transition: all $transition-normal;
&:active {
background: #FFF8F0;
border-color: #FF8C69;
background: $bg;
border-color: $primary;
}
&.active {
background: #FFE8E0;
border-color: #FF8C69;
background: $primary-light;
border-color: $primary;
}
}
.preset-text {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-size: $font-xl;
font-weight: 500;
color: #2D1B1B;
color: $text;
}
.custom-input {
// 底部保存按钮栏
.save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: $space-md $space-xl;
padding-bottom: calc(#{$space-md} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$space-md} + env(safe-area-inset-bottom));
background: $surface;
border-top: 2rpx solid $border;
z-index: 50;
}
.save-btn {
height: 96rpx;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-xl;
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 32rpx;
padding: 24rpx 32rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
&:active {
opacity: 0.8;
transform: scale(0.98);
}
}
.input-prefix {
font-family: 'Fredoka', sans-serif;
font-size: 40rpx;
.save-btn-text {
font-size: $font-xl;
font-weight: 600;
color: #FF8C69;
color: $surface;
}
.input-value {
font-family: 'Fredoka', sans-serif;
font-size: 56rpx;
font-weight: 600;
color: #2D1B1B;
flex: 1;
&.placeholder {
color: #D0C4C4;
}
}
.input-unit {
font-size: 28rpx;
color: #8B7E7E;
}
.input-cursor {
font-family: 'Fredoka', monospace;
font-size: 56rpx;
font-weight: 300;
color: #FF8C69;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.preview-skeleton {
animation: none;
}
.input-cursor {
animation: none;
opacity: 1;
}
}
</style>

View File

@@ -111,7 +111,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useCategoryStore, type Category } from '@/stores/category'
import { waitForReady } from '@/utils/app-ready'
import { getCategoryTransactionCount } from '@/api/category'
@@ -158,6 +158,11 @@ onShow(() => {
if (initialLoaded.value) catStore.fetchCategories()
})
onPullDownRefresh(async () => {
await catStore.fetchCategories()
uni.stopPullDownRefresh()
})
function goBack() {
uni.navigateBack()
}
@@ -268,73 +273,66 @@ async function onMigrateConfirm() {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
@include page-base;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar { background: #FFF8F0; }
.status-bar { @include status-bar; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 40rpx;
padding: $space-sm $space-xl;
}
.nav-back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
@include nav-back;
border-radius: 50%;
&:active { background: #F0E0D6; }
&:active { background: $border; }
}
.title { font-size: 32rpx; font-weight: 600; color: #2D1B1B; }
.title { font-size: $font-xl; font-weight: 600; color: $text; }
.tabs-wrap { display: flex; justify-content: center; margin: 16rpx 0 32rpx; }
.tabs-wrap { display: flex; justify-content: center; margin: $space-sm 0 $space-lg; }
.tabs {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
background: $surface-warm;
border-radius: $space-md;
padding: $space-xs;
}
.tab {
padding: 16rpx 40rpx;
border-radius: 20rpx;
font-size: 28rpx;
color: #8B7E7E;
transition: all 0.2s;
padding: $space-sm $space-xl;
border-radius: $radius-lg;
font-size: $font-lg;
color: $text-sec;
transition: all $transition-normal;
&.active {
background: #FFFFFF;
color: #FF8C69;
background: $surface;
color: $primary;
font-weight: 600;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
box-shadow: $shadow-sm;
}
}
.cat-list { padding: 0 40rpx; }
.cat-list { padding: 0 $space-xl; }
.cat-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #F0E0D6;
padding: $space-md 0;
border-bottom: 1rpx solid $border;
&:last-child { border-bottom: none; }
}
@@ -342,26 +340,26 @@ async function onMigrateConfirm() {
.cat-info {
display: flex;
align-items: center;
gap: 16rpx;
gap: $space-sm;
flex: 1;
padding: 8rpx 0;
padding: $space-xs 0;
&:active { opacity: 0.6; }
}
.cat-name { font-size: 28rpx; color: #2D1B1B; font-weight: 500; }
.cat-name { font-size: $font-lg; color: $text; font-weight: 500; }
.cat-badge {
font-size: 20rpx;
color: #FF8C69;
background: #FFE8E0;
font-size: $font-xs;
color: $primary;
background: $primary-light;
padding: 4rpx 12rpx;
border-radius: 8rpx;
border-radius: $radius-xs;
}
.cat-actions {
display: flex;
gap: 8rpx;
gap: $space-xs;
}
.action-btn {
@@ -372,16 +370,16 @@ async function onMigrateConfirm() {
justify-content: center;
border-radius: 50%;
&:active { background: #F0E0D6; }
&:active { background: $border; }
}
.fab {
position: fixed;
right: 40rpx;
bottom: 120rpx;
right: $space-xl;
bottom: calc(120rpx + env(safe-area-inset-bottom));
width: 112rpx;
height: 112rpx;
background: linear-gradient(135deg, #FF8C69, #E67355);
background: linear-gradient(135deg, $primary, #E67355);
border-radius: 50%;
display: flex;
align-items: center;
@@ -394,8 +392,8 @@ async function onMigrateConfirm() {
.color-picker {
display: flex;
gap: 20rpx;
margin-bottom: 24rpx;
gap: $space-lg;
margin-bottom: $space-md;
flex-wrap: wrap;
}
@@ -404,10 +402,10 @@ async function onMigrateConfirm() {
height: 56rpx;
border-radius: 50%;
border: 4rpx solid transparent;
transition: all 0.2s;
transition: all $transition-normal;
&.selected {
border-color: #2D1B1B;
border-color: $text;
transform: scale(1.15);
}
&:active { opacity: 0.7; }
@@ -425,87 +423,85 @@ async function onMigrateConfirm() {
.modal {
width: 600rpx;
background: #FFFFFF;
border-radius: 32rpx;
padding: 48rpx;
background: $surface;
border-radius: $space-lg;
padding: $space-2xl;
}
.modal-title {
font-size: 32rpx;
font-size: $font-xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
display: block;
text-align: center;
margin-bottom: 32rpx;
margin-bottom: $space-lg;
}
.modal-desc {
font-size: 28rpx;
color: #8B7E7E;
font-size: $font-lg;
color: $text-sec;
display: block;
margin-bottom: 24rpx;
margin-bottom: $space-md;
}
.modal-input {
width: 100%;
height: 96rpx;
background: #FFF8F0;
border-radius: 24rpx;
padding: 0 32rpx;
font-size: 28rpx;
margin-bottom: 24rpx;
box-sizing: border-box;
background: $bg;
border-radius: $space-md;
padding: 0 $space-lg;
font-size: $font-lg;
margin-bottom: $space-md;
}
.migrate-list {
max-height: 400rpx;
margin-bottom: 24rpx;
margin-bottom: $space-md;
}
.migrate-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 20rpx 16rpx;
border-radius: 16rpx;
margin-bottom: 8rpx;
transition: background 0.2s;
gap: $space-sm;
padding: $space-lg $space-sm;
border-radius: $radius-md;
margin-bottom: $space-xs;
transition: background $transition-normal;
&.selected {
background: #FFE8E0;
background: $primary-light;
}
&:active { background: #FFF0E6; }
&:active { background: $surface-warm; }
}
.migrate-name {
font-size: 28rpx;
color: #2D1B1B;
font-size: $font-lg;
color: $text;
font-weight: 500;
}
.modal-btns {
display: flex;
gap: 24rpx;
gap: $space-md;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: 24rpx;
border-radius: $space-md;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
&.cancel {
background: #FFF8F0;
color: #8B7E7E;
background: $bg;
color: $text-sec;
}
&.confirm {
background: linear-gradient(135deg, #FF8C69, #E67355);
color: #FFFFFF;
background: linear-gradient(135deg, $primary, #E67355);
color: $surface;
}
&.disabled { opacity: 0.5; }

View File

@@ -0,0 +1,262 @@
<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="label">反馈类型</text>
<view class="type-grid">
<view
v-for="t in types"
:key="t.value"
class="type-item"
:class="{ active: form.type === t.value }"
@tap="form.type = t.value"
>
<text class="type-text">{{ t.label }}</text>
</view>
</view>
</view>
<!-- 反馈内容 -->
<view class="section">
<text class="label">问题描述</text>
<textarea
class="textarea"
v-model="form.content"
placeholder="请详细描述您遇到的问题或建议..."
maxlength="500"
:show-confirm-bar="false"
/>
<text class="char-count">{{ form.content.length }}/500</text>
</view>
<!-- 联系方式 -->
<view class="section">
<text class="label">联系方式选填</text>
<input
class="input"
v-model="form.contact"
placeholder="微信号或邮箱,方便我们联系您"
maxlength="100"
/>
</view>
<!-- 提交按钮 -->
<view class="submit-btn" :class="{ disabled: submitting || !form.content.trim() }" @tap="onSubmit">
<text class="submit-text">{{ submitting ? '提交中...' : '提交反馈' }}</text>
</view>
<text class="tip">感谢您的反馈我们会认真处理每一条意见</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { statusBarHeight } from '@/utils/system'
import { submitFeedback } from '@/api/feedback'
import Icon from '@/components/Icon/Icon.vue'
const types = [
{ label: '功能异常', value: 'bug' },
{ label: '功能建议', value: 'feature' },
{ label: '体验问题', value: 'ux' },
{ label: '其他', value: 'other' }
]
const form = reactive({
type: 'bug',
content: '',
contact: ''
})
const submitting = ref(false)
async function onSubmit() {
if (submitting.value || !form.content.trim()) return
submitting.value = true
try {
await submitFeedback({
type: form.type,
content: form.content.trim(),
contact: form.contact.trim() || undefined
})
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch (e: any) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
submitting.value = false
}
}
function goBack() {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: $bg;
}
.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;
}
.label {
display: block;
font-size: $font-lg;
font-weight: 500;
color: $text;
margin-bottom: $space-md;
}
.type-grid {
display: flex;
flex-wrap: wrap;
gap: $space-md;
}
.type-item {
padding: $space-sm $space-lg;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
transition: all $transition-normal;
&.active {
background: $primary-light;
border-color: $primary;
}
&:active { opacity: 0.7; }
}
.type-text {
font-size: $font-lg;
color: $text-sec;
.active & {
color: $primary;
font-weight: 500;
}
}
.textarea {
width: 100%;
height: 300rpx;
padding: $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
line-height: 1.6;
box-sizing: border-box;
}
.char-count {
display: block;
text-align: right;
font-size: $font-sm;
color: $text-muted;
margin-top: $space-xs;
}
.input {
width: 100%;
height: 96rpx;
padding: 0 $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
box-sizing: border-box;
}
.submit-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
margin-top: $space-2xl;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&:active { opacity: 0.8; }
}
.submit-text {
font-size: $font-xl;
font-weight: 600;
color: $surface;
}
.tip {
display: block;
text-align: center;
font-size: $font-md;
color: $text-muted;
margin-top: $space-lg;
}
</style>

View File

@@ -75,6 +75,11 @@
<view class="invite-action" @tap="copyInviteCode(group.invite_code)">
<text class="invite-action-text">复制</text>
</view>
<!-- #ifdef MP-WEIXIN -->
<button class="invite-action share-btn" open-type="share" @tap="shareInviteCode = group.invite_code">
<text class="invite-action-text">分享</text>
</button>
<!-- #endif -->
<view v-if="group.role === 'owner'" class="invite-action" @tap="handleRefreshCode(group)">
<Icon name="refresh" :size="24" color="#8B7E7E" />
<text class="invite-action-text">刷新</text>
@@ -135,8 +140,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { onShow, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import { API_BASE } from '@/config'
@@ -144,12 +150,14 @@ import Icon from '@/components/Icon/Icon.vue'
import type { GroupMember, Group } from '@/api/group'
const groupStore = useGroupStore()
const configStore = useConfigStore()
const actionTab = ref<'create' | 'join'>('create')
const newGroupName = ref('')
const inviteCode = ref('')
const saving = ref(false)
const initialLoaded = ref(false)
const shareInviteCode = ref('') // 当前要分享的邀请码
// 成员管理弹窗
const showMemberModal = ref(false)
@@ -168,6 +176,29 @@ onShow(() => {
if (initialLoaded.value) groupStore.fetchGroups()
})
onPullDownRefresh(async () => {
await groupStore.fetchGroups()
uni.stopPullDownRefresh()
})
// #ifdef MP-WEIXIN
onShareAppMessage(() => {
// 空值保护:通过右上角菜单触发时 invite_code 可能为空
const code = shareInviteCode.value
const path = code
? `/pages/group-manage/index?invite_code=${code}`
: '/pages/group-manage/index'
return {
title: '一起来记账吧!',
path
}
})
onShareTimeline(() => ({
title: `${configStore.appName} — 一起来记账吧!`
}))
// #endif
function goBack() {
uni.navigateBack()
}
@@ -325,59 +356,50 @@ function handleDissolve(group: Group) {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
@include page-base;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar { background: #FFF8F0; }
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: 16rpx 40rpx 24rpx;
padding: $space-sm $space-xl $space-md;
}
.nav-back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
@include nav-back;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-size: $font-2xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
}
/* Tab 卡片 */
.action-card {
margin: 24rpx 40rpx 0;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin: $space-md $space-xl 0;
background: $surface;
border-radius: $radius-2xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
overflow: hidden;
}
.tab-bar {
display: flex;
padding: 16rpx 16rpx 0;
gap: 8rpx;
padding: $space-sm $space-sm 0;
gap: $space-xs;
}
.tab-item {
@@ -386,69 +408,69 @@ function handleDissolve(group: Group) {
display: flex;
align-items: center;
justify-content: center;
border-radius: 20rpx 20rpx 0 0;
transition: all 0.2s;
border-radius: $radius-lg $radius-lg 0 0;
transition: all $transition-normal;
&.active {
background: #FFF0E6;
background: $surface-warm;
.tab-text { color: #FF8C69; }
.tab-text { color: $primary; }
}
}
.tab-text {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 500;
color: #8B7E7E;
color: $text-sec;
}
.action-body {
display: flex;
gap: 16rpx;
padding: 24rpx 32rpx 32rpx;
gap: $space-sm;
padding: $space-md $space-lg $space-lg;
}
.action-input {
flex: 1;
height: 80rpx;
padding: 0 24rpx;
background: #FFF8F0;
border-radius: 20rpx;
border: 2rpx solid #F0E0D6;
font-size: 28rpx;
color: #2D1B1B;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
}
.action-btn {
height: 80rpx;
padding: 0 32rpx;
background: linear-gradient(135deg, #FF8C69, #E67355);
border-radius: 20rpx;
padding: 0 $space-lg;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
gap: $space-xs;
&.disabled { opacity: 0.5; }
&:active { opacity: 0.8; }
}
.action-btn-text {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
color: #FFFFFF;
color: $surface;
white-space: nowrap;
}
/* 群组列表 */
.section-header {
padding: 40rpx 40rpx 16rpx;
padding: $space-xl $space-xl $space-sm;
}
.section-title {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 500;
color: #8B7E7E;
color: $text-sec;
}
.loading-box, .empty-box {
@@ -456,36 +478,36 @@ function handleDissolve(group: Group) {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
gap: $space-sm;
padding: 80rpx 0;
}
.loading-text, .empty-text {
font-size: 28rpx;
color: #BFB3B3;
font-size: $font-lg;
color: $text-muted;
}
.group-list {
padding: 0 40rpx;
padding: 0 $space-xl;
}
.group-item {
display: flex;
align-items: flex-start;
padding: 28rpx 32rpx;
margin-bottom: 16rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
gap: 24rpx;
padding: $font-lg $space-lg;
margin-bottom: $space-sm;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
gap: $space-md;
}
.group-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: #FFF0E6;
background: $surface-warm;
display: flex;
align-items: center;
justify-content: center;
@@ -505,24 +527,24 @@ function handleDissolve(group: Group) {
}
.group-name {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
color: #2D1B1B;
color: $text;
}
.role-badge {
padding: 2rpx 12rpx;
border-radius: 8rpx;
border-radius: $radius-xs;
}
.role-badge-text {
font-size: 20rpx;
font-size: $font-xs;
font-weight: 500;
}
.group-meta {
font-size: 24rpx;
color: #BFB3B3;
font-size: $font-md;
color: $text-muted;
display: block;
margin-top: 4rpx;
}
@@ -532,19 +554,19 @@ function handleDissolve(group: Group) {
display: flex;
align-items: center;
margin-top: 12rpx;
gap: 8rpx;
gap: $space-xs;
flex-wrap: wrap;
}
.invite-label {
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
}
.invite-code {
font-size: 24rpx;
font-size: $font-md;
font-weight: 600;
color: #FF8C69;
color: $primary;
font-family: 'Fredoka', monospace;
}
@@ -553,85 +575,91 @@ function handleDissolve(group: Group) {
align-items: center;
gap: 4rpx;
padding: 4rpx 12rpx;
background: #FFF8F0;
border-radius: 12rpx;
background: $bg;
border-radius: $radius-sm;
&:active { background: #F0E0D6; }
&:active { background: $border; }
}
.invite-action-text {
font-size: 22rpx;
color: #8B7E7E;
font-size: $font-sm;
color: $text-sec;
}
/* 分享按钮重置默认样式 */
.share-btn {
margin: 0;
padding: 4rpx 12rpx;
line-height: normal;
background: $bg;
border: none;
border-radius: $radius-sm;
font-size: inherit;
color: inherit;
&::after { border: none; }
}
/* 操作按钮 */
.group-actions {
display: flex;
flex-direction: column;
gap: 8rpx;
gap: $space-xs;
flex-shrink: 0;
}
.group-btn {
padding: 12rpx 24rpx;
border-radius: 16rpx;
padding: 12rpx $space-md;
border-radius: $radius-md;
display: flex;
align-items: center;
justify-content: center;
&.manage { background: #FFF0E6; }
&.leave { background: #FFF0E6; }
&.dissolve { background: #FFE8E8; }
&.manage { background: $surface-warm; }
&.leave { background: $surface-warm; }
&.dissolve { background: $danger-light; }
&:active { opacity: 0.7; }
}
.group-btn-text {
font-size: 24rpx;
font-size: $font-md;
font-weight: 500;
white-space: nowrap;
&.manage-text { color: #FF8C69; }
&.leave-text { color: #FF8C69; }
&.dissolve-text { color: #FF6B6B; }
&.manage-text { color: $primary; }
&.leave-text { color: $primary; }
&.dissolve-text { color: $danger; }
}
/* 成员管理弹窗 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: flex-end;
@include modal-mask;
}
.member-modal {
width: 100%;
max-height: 75vh;
background: #FFFFFF;
border-radius: 40rpx 40rpx 0 0;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
padding-bottom: env(safe-area-inset-bottom);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 40rpx 24rpx;
border-bottom: 2rpx solid #F0E0D6;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
.modal-title {
font-size: 32rpx;
font-size: $font-xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
}
.modal-close {
@@ -641,14 +669,14 @@ function handleDissolve(group: Group) {
align-items: center;
justify-content: center;
border-radius: 50%;
background: #F0E0D6;
background: $border;
&:active { background: #E0D6D0; }
&:active { background: darken($border, 5%); }
}
.modal-close-text {
font-size: 36rpx;
color: #8B7E7E;
font-size: $font-2xl;
color: $text-sec;
line-height: 1;
}
@@ -661,19 +689,17 @@ function handleDissolve(group: Group) {
.member-list {
max-height: 60vh;
padding: 16rpx 40rpx 40rpx;
box-sizing: border-box;
padding: $space-sm $space-xl $space-xl;
width: 100%;
}
.member-item {
display: flex;
align-items: center;
padding: 20rpx 0;
gap: 20rpx;
padding: $space-lg 0;
gap: $space-lg;
border-bottom: 2rpx solid #F8F0EB;
width: 100%;
box-sizing: border-box;
&:last-child { border-bottom: none; }
}
@@ -682,7 +708,7 @@ function handleDissolve(group: Group) {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #F0E0D6;
background: $border;
flex-shrink: 0;
}
@@ -692,9 +718,9 @@ function handleDissolve(group: Group) {
}
.member-name {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 500;
color: #2D1B1B;
color: $text;
display: block;
overflow: hidden;
text-overflow: ellipsis;
@@ -706,22 +732,22 @@ function handleDissolve(group: Group) {
}
.member-role-text {
font-size: 22rpx;
font-size: $font-sm;
}
.member-remove {
padding: 12rpx 24rpx;
background: #FFE8E8;
border-radius: 16rpx;
padding: 12rpx $space-md;
background: $danger-light;
border-radius: $radius-md;
flex-shrink: 0;
&:active { opacity: 0.7; }
}
.member-remove-text {
font-size: 24rpx;
font-size: $font-md;
font-weight: 500;
color: #FF6B6B;
color: $danger;
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -121,7 +121,7 @@
<text class="urgent-title">{{ urgentNotice.title }}</text>
</view>
<scroll-view class="urgent-body" scroll-y>
<image v-if="urgentNotice.image_url" class="urgent-image" :src="urgentNotice.image_url" mode="widthFix" />
<image v-if="urgentNotice.image_url" class="urgent-image" :src="getNotificationImageUrl(urgentNotice.image_url)" mode="widthFix" />
<text class="urgent-content">{{ urgentNotice.content }}</text>
<view v-if="urgentNotice.link_url" class="urgent-link" @tap="openLink(urgentNotice.link_url)">
<text class="urgent-link-text">查看详情</text>
@@ -146,6 +146,9 @@ import { useGroupStore } from '@/stores/group'
import { useNotificationStore } from '@/stores/notification'
import { waitForReady } from '@/utils/app-ready'
import { getOverview } from '@/api/stats'
import { syncRecurring } from '@/api/recurring'
import type { Transaction } from '@/api/transaction'
import { getNotificationImageUrl } from '@/api/notification'
import { formatAmount, formatAmountRaw } from '@/utils/format'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
@@ -164,7 +167,7 @@ const DEFAULT_BUDGET = 500000 // 5000元
const overview = computed(() => statsStore.overview)
const budget = computed(() => budgetStore.budget)
const recentTx = ref<any[]>([])
const recentTx = ref<Transaction[]>([])
const loading = ref(true)
const loadError = ref(false)
const todayExpense = ref(0)
@@ -203,26 +206,31 @@ function openLink(url: string) {
async function loadData(silent = false) {
await waitForReady()
try {
if (!silent) loading.value = true
loadError.value = false
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
// 关键数据:任一失败则显示错误
// 第一级首屏关键数据2个请求
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1 }),
fetchTodayData(today)
txStore.fetchTransactions({ page: 1 })
])
recentTx.value = txStore.transactions.slice(0, 5)
// 非关键数据:失败不影响页面显示
// 第二级:次要数据(异步,不阻塞首屏)
Promise.all([
budgetStore.fetchBudget(),
fetchTodayData()
]).catch(() => {})
// 第三级:非关键数据
Promise.allSettled([
userStore.fetchUserInfo(),
notifStore.fetchUnreadCount(),
notifStore.fetchUrgentNotifications()
])
notifStore.fetchUrgentNotifications(),
syncRecurring().catch(() => {})
]).catch(() => {})
} catch (e) {
console.error('Load error:', e)
if (!silent) loadError.value = true
@@ -231,8 +239,10 @@ async function loadData(silent = false) {
}
}
async function fetchTodayData(today: string) {
async function fetchTodayData() {
try {
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const data = await getOverview({ startDate: today, endDate: today, group_id: groupStore.currentGroupId })
todayExpense.value = data.expense || 0
todayIncome.value = data.income || 0
@@ -280,38 +290,33 @@ function goBills() {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
@include page-base;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar {
background: #FFF8F0;
@include status-bar;
}
.header {
display: flex;
align-items: center;
padding: 16rpx 40rpx 32rpx;
padding: $space-sm $space-xl $space-lg;
}
.avatar-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
border: 2rpx solid #F0E0D6;
background: #FFF0E6;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid $border;
background: $surface-warm;
@include flex-center;
overflow: hidden;
}
@@ -322,71 +327,63 @@ function goBills() {
.greeting {
flex: 1;
font-size: 36rpx;
font-size: $font-2xl;
font-weight: 600;
color: #2D1B1B;
margin-left: 16rpx;
color: $text;
margin-left: $space-sm;
}
.bell {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
@include flex-center;
position: relative;
&:active { opacity: 0.6; }
}
.bell-badge {
position: absolute;
top: 8rpx;
right: 8rpx;
top: $space-xs;
right: $space-xs;
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
background: #FF6B6B;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 0 $space-xs;
background: $danger;
border-radius: $radius-full;
@include flex-center;
}
.bell-badge-text {
font-size: 18rpx;
font-size: $font-xs;
font-weight: 600;
color: #FFFFFF;
color: $surface;
}
.overview-card {
margin: 0 40rpx;
padding: 48rpx;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08),
inset -2rpx -2rpx 6rpx rgba(45, 27, 27, 0.04);
margin: 0 $space-xl;
padding: $space-2xl;
@include card($radius-2xl);
box-shadow: $shadow-clay;
}
.card-label {
font-size: 28rpx;
color: #8B7E7E;
font-size: $font-lg;
color: $text-sec;
display: block;
margin-bottom: 12rpx;
margin-bottom: $space-sm;
}
.amount-row {
display: flex;
align-items: baseline;
margin-bottom: 32rpx;
margin-bottom: $space-lg;
}
.currency {
font-family: 'Fredoka', sans-serif;
font-size: 44rpx;
font-size: $font-3xl;
font-weight: 500;
color: #8B7E7E;
color: $text-sec;
margin-right: 4rpx;
}
@@ -394,195 +391,168 @@ function goBills() {
font-family: 'Fredoka', sans-serif;
font-size: 72rpx;
font-weight: 600;
color: #2D1B1B;
color: $text;
font-variant-numeric: tabular-nums;
}
.mini-stats {
display: flex;
gap: 24rpx;
margin-top: 40rpx;
gap: $space-md;
margin-top: $space-xl;
}
.mini-stat {
flex: 1;
min-width: 0;
padding: 24rpx 12rpx;
border-radius: 24rpx;
background: #FFF8F0;
padding: $space-md $space-sm;
border-radius: $radius-xl;
background: $bg;
text-align: center;
}
.mini-label {
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: 8rpx;
margin-bottom: $space-xs;
}
.mini-value {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-size: $font-xl;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@include text-ellipsis;
&.income { color: #7BC67E; }
&.expense { color: #FF6B6B; }
&.income { color: $success; }
&.expense { color: $danger; }
}
.today-card {
margin: 24rpx 40rpx 0;
padding: 28rpx 32rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin: $space-md $space-xl 0;
padding: $space-lg $space-lg;
@include card($radius-xl);
display: flex;
align-items: center;
gap: 24rpx;
gap: $space-md;
}
.today-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
gap: $space-xs;
}
.today-label {
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
}
.today-amount {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-size: $font-xl;
font-weight: 600;
&.expense { color: #FF6B6B; }
&.income { color: #7BC67E; }
&.expense { color: $danger; }
&.income { color: $success; }
}
.today-divider {
width: 2rpx;
height: 56rpx;
background: #F0E0D6;
background: $border;
flex-shrink: 0;
}
.today-count {
font-size: 22rpx;
color: #BFB3B3;
font-size: $font-sm;
color: $text-muted;
flex-shrink: 0;
}
.quick-actions {
display: flex;
gap: 24rpx;
padding: 0 40rpx;
margin-top: 40rpx;
gap: $space-md;
padding: 0 $space-xl;
margin-top: $space-xl;
}
.quick-btn {
flex: 1;
height: 144rpx;
border-radius: 32rpx;
background: #FFFFFF;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
display: flex;
border-radius: $radius-2xl;
@include card;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
@include flex-center;
gap: $space-xs;
&:active {
transform: scale(0.97);
box-shadow: inset 2rpx 2rpx 6rpx rgba(45, 27, 27, 0.1);
box-shadow: $shadow-clay-pressed;
}
}
.quick-text { font-size: 24rpx; font-weight: 500; color: #2D1B1B; }
.quick-text { font-size: $font-md; font-weight: 500; color: $text; }
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 48rpx 40rpx 24rpx;
@include flex-between;
padding: $space-2xl $space-xl $space-md;
}
.section-title {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 500;
color: #8B7E7E;
color: $text-sec;
}
.section-more {
font-size: 24rpx;
color: #FF8C69;
padding: 16rpx 24rpx;
margin: -16rpx -24rpx;
font-size: $font-md;
color: $primary;
padding: $space-sm $space-md;
margin: -$space-sm -#{$space-md};
&:active { opacity: 0.6; }
}
.tx-list {
padding: 0 40rpx;
padding: 0 $space-xl;
}
.amount-skeleton {
margin-bottom: 32rpx;
margin-bottom: $space-lg;
}
.skeleton-item {
display: flex;
align-items: center;
padding: 28rpx 0;
gap: 24rpx;
padding: $space-lg 0;
gap: $space-md;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
gap: $space-xs;
}
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 80rpx 0;
@include state-box;
}
.state-text {
font-size: 28rpx;
color: #BFB3B3;
@include state-text;
}
/* 强提醒公告弹窗 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
@include modal-mask-high;
}
.urgent-modal {
width: 80%;
max-height: 70vh;
background: #FFFFFF;
border-radius: 32rpx;
background: $surface;
border-radius: $radius-2xl;
overflow: hidden;
display: flex;
flex-direction: column;
@@ -591,52 +561,52 @@ function goBills() {
.urgent-header {
display: flex;
align-items: center;
gap: 12rpx;
padding: 32rpx 32rpx 16rpx;
gap: $space-sm;
padding: $space-xl $space-lg $space-md;
}
.urgent-title {
font-size: 32rpx;
font-size: $font-xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
flex: 1;
}
.urgent-body {
flex: 1;
padding: 0 32rpx 24rpx;
padding: 0 $space-lg $space-lg;
max-height: 50vh;
}
.urgent-image {
width: 100%;
border-radius: 16rpx;
margin-bottom: 16rpx;
border-radius: $radius-md;
margin-bottom: $space-sm;
}
.urgent-content {
font-size: 28rpx;
color: #2D1B1B;
font-size: $font-lg;
color: $text;
line-height: 1.6;
}
.urgent-link {
margin-top: 16rpx;
padding: 12rpx 0;
margin-top: $space-sm;
padding: $space-xs 0;
}
.urgent-link-text {
font-size: 28rpx;
color: #FF8C69;
font-size: $font-lg;
color: $primary;
text-decoration: underline;
}
.urgent-footer {
padding: 24rpx 32rpx;
border-top: 2rpx solid #F0E0D6;
padding: $space-lg;
border-top: 2rpx solid $border;
text-align: center;
&:active { background: #FFF8F0; }
&:active { background: $surface-warm; }
}
.urgent-btn-text {

View File

@@ -39,24 +39,15 @@
</view>
<view class="notif-content">
<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>
</view>
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
<image v-if="item.image_url" class="notif-image" :src="item.image_url" mode="widthFix" />
<image v-if="item.image_url" class="notif-image" :src="getNotificationImageUrl(item.image_url)" mode="widthFix" />
<view class="notif-meta">
<text class="notif-time">{{ formatTime(item.created_at) }}</text>
<text v-if="item.link_url" class="notif-link" @tap.stop="openLink(item.link_url)">查看详情</text>
</view>
<!-- 管理员操作 -->
<view v-if="isAdmin && item.type === 'system'" class="admin-actions">
<view class="admin-btn" @tap.stop="handleTogglePin(item)">
<text class="admin-btn-text">{{ item.is_pinned ? '取消置顶' : '置顶' }}</text>
</view>
<view class="admin-btn danger" @tap.stop="handleDeleteNotif(item)">
<text class="admin-btn-text danger-text">删除</text>
</view>
</view>
</view>
<view v-if="!item.is_read" class="unread-dot"></view>
</view>
@@ -65,23 +56,40 @@
<text class="no-more-text">没有更多了</text>
</view>
</view>
<!-- 通知详情弹窗 -->
<view class="modal-mask" v-if="detailItem" @tap="detailItem = null">
<view class="detail-modal" @tap.stop>
<view class="detail-header">
<text class="detail-title">{{ detailItem.title }}</text>
<view class="detail-close" @tap="detailItem = null">
<text class="detail-close-text">×</text>
</view>
</view>
<scroll-view class="detail-body" scroll-y>
<image v-if="detailItem.image_url" class="detail-image" :src="getNotificationImageUrl(detailItem.image_url)" mode="widthFix" />
<text class="detail-content" v-if="detailItem.content">{{ detailItem.content }}</text>
<view class="detail-meta">
<text class="detail-time">{{ formatTime(detailItem.created_at) }}</text>
<text v-if="detailItem.link_url" class="detail-link" @tap="openLink(detailItem.link_url)">查看详情</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom } from '@dcloudio/uni-app'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useNotificationStore } from '@/stores/notification'
import { useUserStore } from '@/stores/user'
import { getNotifications, markRead, markAllRead, deleteNotification, updateNotification } from '@/api/notification'
import { getNotifications, markRead, markAllRead, getNotificationImageUrl } from '@/api/notification'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
import type { Notification } from '@/api/notification'
const notifStore = useNotificationStore()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.userInfo?.role === 'admin')
const tabs = [
{ key: 'all', label: '全部' },
@@ -95,6 +103,8 @@ const list = ref<Notification[]>([])
const page = ref(1)
const total = ref(0)
const loading = ref(false)
const detailItem = ref<Notification | null>(null)
const initialLoaded = ref(false)
let loadSeq = 0
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
@@ -102,10 +112,16 @@ const noMore = computed(() => list.value.length >= total.value && total.value >
onMounted(async () => {
await waitForReady()
await loadNotifications(true)
initialLoaded.value = true
})
onShow(() => {
if (!initialLoaded.value) return
notifStore.fetchUnreadCount()
// 返回页面时刷新列表(如全部已读后)
if (list.value.length > 0) {
loadNotifications(true)
}
})
function switchTab(key: string) {
@@ -171,6 +187,7 @@ function formatTime(dateStr: string) {
}
async function handleTap(item: Notification) {
// 标记已读
if (!item.is_read) {
try {
await markRead(item.id)
@@ -180,6 +197,8 @@ async function handleTap(item: Notification) {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
// 显示详情
detailItem.value = item
}
async function handleMarkAllRead() {
@@ -205,40 +224,15 @@ function openLink(url: string) {
// #endif
}
/** 管理员:切换置顶状态 */
async function handleTogglePin(item: Notification) {
try {
await updateNotification(item.id, { is_pinned: !item.is_pinned })
item.is_pinned = item.is_pinned ? 0 : 1
uni.showToast({ title: item.is_pinned ? '已置顶' : '已取消置顶', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
/** 管理员:删除公告 */
function handleDeleteNotif(item: Notification) {
uni.showModal({
title: '删除公告',
content: `确定删除「${item.title}」?`,
success: async (res) => {
if (res.confirm) {
try {
await deleteNotification(item.id)
list.value = list.value.filter(n => n.id !== item.id)
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
function goBack() {
uni.navigateBack()
}
onPullDownRefresh(async () => {
await loadNotifications(true)
uni.stopPullDownRefresh()
})
onReachBottom(() => {
if (noMore.value || loading.value) return
page.value++
@@ -247,75 +241,67 @@ onReachBottom(() => {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
@include page-base;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar { background: #FFF8F0; }
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: 16rpx 40rpx 24rpx;
padding: $space-sm $space-xl $space-md;
}
.nav-back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
@include nav-back;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-size: $font-2xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
}
.nav-action {
padding: 8rpx 16rpx;
padding: $space-xs $space-sm;
&:active { opacity: 0.6; }
}
.nav-action-text {
font-size: 26rpx;
color: #FF8C69;
font-size: $font-base;
color: $primary;
}
.tabs-wrap { display: flex; justify-content: center; margin: 0 0 24rpx; }
.tabs-wrap { display: flex; justify-content: center; margin: 0 0 $space-md; }
.tabs {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
background: $surface-warm;
border-radius: $space-md;
padding: $space-xs;
}
.tab {
padding: 12rpx 32rpx;
border-radius: 20rpx;
font-size: 26rpx;
color: #8B7E7E;
transition: all 0.2s;
padding: 12rpx $space-lg;
border-radius: $radius-lg;
font-size: $font-base;
color: $text-sec;
transition: all $transition-normal;
&.active {
background: #FFFFFF;
color: #FF8C69;
background: $surface;
color: $primary;
font-weight: 600;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
box-shadow: $shadow-sm;
}
}
@@ -323,31 +309,31 @@ onReachBottom(() => {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
gap: $space-sm;
padding: 120rpx 0;
}
.loading-text, .empty-text {
font-size: 28rpx;
color: #BFB3B3;
font-size: $font-lg;
color: $text-muted;
}
.notification-list { padding: 0 40rpx; }
.notification-list { padding: 0 $space-xl; }
.notification-item {
display: flex;
align-items: flex-start;
gap: 20rpx;
padding: 28rpx 32rpx;
margin-bottom: 16rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
gap: $space-lg;
padding: $font-lg $space-lg;
margin-bottom: $space-sm;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
position: relative;
overflow: hidden;
&.unread { border-left: 6rpx solid #FF8C69; }
&.unread { border-left: 6rpx solid $primary; }
}
.notif-icon {
@@ -365,25 +351,30 @@ onReachBottom(() => {
.notif-title-row {
display: flex;
align-items: center;
gap: 8rpx;
gap: $space-xs;
}
.pin-badge {
font-size: 24rpx;
font-size: $font-xs;
color: $primary;
background: #FFE8E0;
padding: 2rpx 10rpx;
border-radius: $radius-xs;
font-weight: 500;
}
.notif-title {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
color: #2D1B1B;
color: $text;
display: block;
}
.notif-body {
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
display: block;
margin-top: 8rpx;
margin-top: $space-xs;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -391,70 +382,137 @@ onReachBottom(() => {
.notif-image {
width: 100%;
border-radius: 12rpx;
margin-top: 12rpx;
border-radius: $radius-md;
margin-top: $space-sm;
}
.notif-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8rpx;
margin-top: $space-xs;
}
.notif-time {
font-size: 22rpx;
color: #BFB3B3;
font-size: $font-sm;
color: $text-muted;
}
.unread-dot {
width: 16rpx;
height: 16rpx;
width: $space-sm;
height: $space-sm;
border-radius: 50%;
background: #FF8C69;
background: $primary;
flex-shrink: 0;
margin-top: 8rpx;
margin-top: $space-xs;
}
.notif-link {
font-size: 22rpx;
color: #FF8C69;
font-size: $font-sm;
color: $primary;
text-decoration: underline;
}
.pinned {
border-color: #FF8C69;
border-color: $primary;
background: #FFFAF7;
}
.no-more {
text-align: center;
padding: 32rpx 0;
padding: $space-lg 0;
}
.no-more-text {
font-size: 24rpx;
color: #BFB3B3;
font-size: $font-md;
color: $text-muted;
}
.admin-actions {
/* 通知详情弹窗 */
.modal-mask {
@include modal-mask;
}
.detail-modal {
width: 100%;
max-height: 80vh;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
gap: 12rpx;
margin-top: 12rpx;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
}
.admin-btn {
padding: 8rpx 16rpx;
background: #FFF0E6;
border-radius: 12rpx;
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
&.danger { background: #FFE8E8; }
.detail-title {
flex: 1;
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.detail-close {
width: 56rpx;
height: 56rpx;
@include flex-center;
border-radius: 50%;
background: $border;
margin-left: $space-md;
&:active { background: darken($border, 5%); }
}
.detail-close-text {
font-size: $font-2xl;
color: $text-sec;
line-height: 1;
}
.detail-body {
flex: 1;
padding: $space-md $space-xl $space-xl;
max-height: 60vh;
}
.detail-image {
width: 100%;
border-radius: $radius-md;
margin-bottom: $space-md;
}
.detail-content {
font-size: $font-lg;
color: $text;
line-height: 1.8;
display: block;
white-space: pre-wrap;
word-break: break-all;
}
.detail-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: $space-lg;
padding-top: $space-md;
border-top: 2rpx solid $border;
}
.detail-time {
font-size: $font-sm;
color: $text-muted;
}
.detail-link {
font-size: $font-sm;
color: $primary;
text-decoration: underline;
&:active { opacity: 0.7; }
}
.admin-btn-text {
font-size: 22rpx;
color: #FF8C69;
&.danger-text { color: #FF6B6B; }
}
</style>

View File

@@ -0,0 +1,155 @@
<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>
<scroll-view class="content" scroll-y>
<text class="update-date">更新日期2026年6月8日</text>
<text class="section-title">引言</text>
<text class="paragraph">小菜记账以下简称"我们"非常重视用户的隐私保护本隐私政策旨在向您说明我们如何收集使用存储和保护您的个人信息请您仔细阅读</text>
<text class="section-title">我们收集的信息</text>
<text class="paragraph">在您使用本小程序时我们可能收集以下信息</text>
<text class="item">1. 微信 openid用于识别用户身份实现登录功能</text>
<text class="item">2. 用户昵称用于个性化展示您可以自行设置或修改</text>
<text class="item">3. 用户头像用于个性化展示您可以选择上传自定义头像</text>
<text class="item">4. 记账数据包括金额分类备注日期等仅用于为您提供记账服务</text>
<text class="section-title">信息的使用目的</text>
<text class="paragraph">我们收集的信息仅用于以下目的</text>
<text class="item">1. 提供记账统计预算等核心功能服务</text>
<text class="item">2. 实现群组记账功能与您邀请的好友共享账单数据</text>
<text class="item">3. 发送系统公告和重要通知</text>
<text class="item">4. 改善产品功能和用户体验</text>
<text class="section-title">信息的存储与保护</text>
<text class="paragraph">1. 您的数据存储在我们的服务器数据库中采用加密传输和访问控制</text>
<text class="paragraph">2. 我们不会将您的个人信息出售给第三方</text>
<text class="paragraph">3. 除法律法规要求外我们不会向任何第三方披露您的个人信息</text>
<text class="section-title">群组功能说明</text>
<text class="paragraph">1. 群组功能仅用于聚合展示成员的记账数据记录始终属于记录者本人</text>
<text class="paragraph">2. 群组成员可以看到其他成员的记账记录金额分类日期</text>
<text class="paragraph">3. 您可以随时退出群组退出后您的记录将回到个人账本</text>
<text class="section-title">您的权利</text>
<text class="paragraph">您享有以下权利</text>
<text class="item">1. 查看和修改您的个人信息昵称头像</text>
<text class="item">2. 删除您的记账记录</text>
<text class="item">3. 退出或解散群组</text>
<text class="item">4. 导出您的账单数据</text>
<text class="section-title">未成年人保护</text>
<text class="paragraph">如果您是未满 18 周岁的未成年人请在监护人的陪同下阅读本政策并在监护人同意后使用本服务</text>
<text class="section-title">政策更新</text>
<text class="paragraph">我们可能会不时更新本隐私政策更新后的政策将在小程序内公布继续使用本服务即视为您同意更新后的政策</text>
<text class="section-title">联系我们</text>
<text class="paragraph">如您对本隐私政策有任何疑问请通过小程序内的关于小菜联系我们</text>
<view class="bottom-space"></view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
function goBack() {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: $bg;
}
.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 {
height: calc(100vh - 200rpx);
padding: $space-xl;
}
.update-date {
display: block;
font-size: $font-md;
color: $text-muted;
margin-bottom: $space-xl;
}
.section-title {
display: block;
font-size: $font-xl;
font-weight: 600;
color: $text;
margin-top: $space-2xl;
margin-bottom: $space-md;
}
.paragraph {
display: block;
font-size: $font-lg;
color: $text-sec;
line-height: 1.8;
margin-bottom: $space-sm;
}
.item {
display: block;
font-size: $font-lg;
color: $text-sec;
line-height: 1.8;
padding-left: $space-md;
}
.bottom-space {
height: 120rpx;
}
</style>

View File

@@ -29,12 +29,18 @@
<text class="form-label">昵称</text>
<input class="form-input" v-model="formNickname" placeholder="请输入昵称" maxlength="50" />
</view>
<view class="form-divider"></view>
<view class="form-item">
<text class="form-label">签名</text>
<input class="form-input" v-model="formSlogan" placeholder="写一句个性签名吧" maxlength="100" />
</view>
</view>
<view class="tips-card">
<text class="tips-title">提示</text>
<text class="tips-text"> 昵称最长 50 个字符</text>
<text class="tips-text"> 头像支持 JPGPNG 格式最大 2MB</text>
<text class="tips-text"> 签名最长 100 个字符</text>
<text class="tips-text"> 头像支持 JPGPNGWebP 格式最大 2MB</text>
</view>
</view>
</template>
@@ -48,6 +54,7 @@ import Icon from '@/components/Icon/Icon.vue'
const userStore = useUserStore()
const formNickname = ref('')
const formSlogan = ref('')
const previewUrl = ref('')
const uploading = ref(false)
const saving = ref(false)
@@ -55,6 +62,7 @@ const saving = ref(false)
onMounted(async () => {
await waitForReady()
formNickname.value = userStore.nickname
formSlogan.value = userStore.slogan
previewUrl.value = userStore.avatarUrl
})
@@ -93,7 +101,10 @@ async function saveProfile() {
}
saving.value = true
try {
await userStore.updateProfile(nickname)
await userStore.updateProfile({
nickname,
slogan: formSlogan.value.trim()
})
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch {
@@ -105,43 +116,34 @@ async function saveProfile() {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
@include page-base;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar { background: #FFF8F0; }
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: 16rpx 40rpx 24rpx;
padding: $space-sm $space-xl $space-md;
}
.nav-back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
@include nav-back;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-size: $font-2xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
}
.nav-save {
@@ -155,16 +157,16 @@ async function saveProfile() {
}
.save-text {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
color: #FF8C69;
color: $primary;
}
.avatar-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 48rpx 0 32rpx;
padding: $space-2xl 0 $space-lg;
}
.avatar-wrap {
@@ -172,7 +174,7 @@ async function saveProfile() {
height: 160rpx;
border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.5);
background: linear-gradient(135deg, #FF8C69, #FFB89A);
background: linear-gradient(135deg, $primary, #FFB89A);
display: flex;
align-items: center;
justify-content: center;
@@ -196,61 +198,67 @@ async function saveProfile() {
}
.avatar-hint {
font-size: 24rpx;
color: #BFB3B3;
margin-top: 16rpx;
font-size: $font-md;
color: $text-muted;
margin-top: $space-sm;
}
.form-card {
margin: 32rpx 40rpx 0;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin: $space-lg $space-xl 0;
background: $surface;
border-radius: $radius-2xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
overflow: hidden;
}
.form-item {
display: flex;
align-items: center;
padding: 32rpx 40rpx;
padding: $space-lg $space-xl;
}
.form-divider {
height: 2rpx;
background: $border;
margin: 0 $space-xl;
}
.form-label {
width: 120rpx;
font-size: 28rpx;
font-size: $font-lg;
font-weight: 500;
color: #2D1B1B;
color: $text;
flex-shrink: 0;
}
.form-input {
flex: 1;
font-size: 28rpx;
color: #2D1B1B;
font-size: $font-lg;
color: $text;
}
.tips-card {
margin: 32rpx 40rpx 0;
padding: 32rpx 40rpx;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin: $space-lg $space-xl 0;
padding: $space-lg $space-xl;
background: $surface;
border-radius: $radius-2xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
}
.tips-title {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
color: #2D1B1B;
color: $text;
display: block;
margin-bottom: 16rpx;
margin-bottom: $space-sm;
}
.tips-text {
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: 8rpx;
margin-bottom: $space-xs;
}
</style>

View File

@@ -12,7 +12,7 @@
</view>
<view class="p-info">
<text class="p-name">{{ userStore.nickname }}</text>
<text class="p-tagline">记账小能手</text>
<text class="p-tagline">{{ userStore.slogan }}</text>
<text class="p-streak">累计 {{ txStore.total }} 笔记录</text>
</view>
<Icon name="chevronRight" :size="28" color="rgba(255,255,255,0.6)" />
@@ -51,17 +51,29 @@
<text class="mi-label">分类管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="exportData">
<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>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<view class="menu-card mt">
<view class="menu-item" @tap="goFeedback">
<text class="mi-label">意见反馈</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="showAbout = true">
<text class="mi-label">关于小菜</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goPrivacy">
<text class="mi-label">隐私政策</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<!-- 管理员入口 -->
@@ -76,16 +88,70 @@
<!-- 关于弹窗 -->
<view class="modal-mask" v-if="showAbout" @tap="showAbout = false">
<view class="modal" @tap.stop>
<text class="about-title">小菜记账</text>
<text class="about-version">v1.0.0</text>
<text class="about-desc">温暖 x 轻松的个人记账小程序</text>
<text class="about-desc">让记账从负担变成享受</text>
<text class="about-title">{{ configStore.appName }}</text>
<text class="about-version">v{{ configStore.config.app_version || '1.0.0' }}</text>
<text class="about-desc">{{ configStore.appSlogan }}</text>
<text class="about-desc">{{ configStore.appDescription }}</text>
<view class="modal-btns modal-btns-top">
<view class="modal-btn confirm" @tap="showAbout = false">知道了</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-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="identity-modal" @tap.stop>
@@ -127,14 +193,16 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import { useStatsStore } from '@/stores/stats'
import { useBudgetStore } from '@/stores/budget'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction'
import type { Transaction } from '@/api/transaction'
import { formatAmount } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
@@ -144,6 +212,7 @@ const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const configStore = useConfigStore()
const overview = computed(() => statsStore.overview)
const budget = computed(() => budgetStore.budget)
@@ -152,6 +221,35 @@ const showAbout = ref(false)
const showIdentity = 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 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 () => {
await waitForReady()
loading.value = true
@@ -185,6 +283,20 @@ onShow(async () => {
} catch {}
})
onPullDownRefresh(async () => {
try {
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
groupStore.fetchGroups(),
userStore.fetchUserInfo(),
configStore.fetchConfig()
])
} catch {}
uni.stopPullDownRefresh()
})
function goBudget() {
uni.navigateTo({ url: '/pages/budget/index' })
}
@@ -193,6 +305,18 @@ function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' })
}
function goRecurring() {
uni.navigateTo({ url: '/pages/recurring/index' })
}
function goPrivacy() {
uni.navigateTo({ url: '/pages/privacy/index' })
}
function goFeedback() {
uni.navigateTo({ url: '/pages/feedback/index' })
}
function goProfileEdit() {
uni.navigateTo({ url: '/pages/profile-edit/index' })
}
@@ -290,18 +414,30 @@ function getLocalDateStr(): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
async function exportData() {
uni.showLoading({ title: '导出中...' })
async function doExport() {
if (exportCount.value === 0 || exportLoading.value) return
exportLoading.value = true
uni.showLoading({ title: '导出中 0%' })
try {
// 分页获取全部数据(服务端 pageSize 上限 100
const allList: any[] = []
// 分页获取筛选后的数据
const allList: Transaction[] = []
let page = 1
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 {
const data = await getTransactions({ page, pageSize: 100, group_id: groupStore.currentGroupId })
const data = await getTransactions({ page, ...baseParams } as any)
allList.push(...data.list)
total = data.total
page++
const progress = Math.min(100, Math.round((allList.length / total) * 100))
uni.showLoading({ title: `导出中 ${progress}%` })
} while (allList.length < total)
if (allList.length === 0) {
@@ -310,6 +446,18 @@ async function exportData() {
return
}
if (exportFormat.value === 'json') {
const json = JSON.stringify(allList.map(t => ({
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, '""') + '"'
@@ -324,13 +472,25 @@ async function exportData() {
}).join('\n')
const csv = '' + header + rows
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.csv`
saveFile(csv, fileName, 'text/csv')
}
} catch {
uni.hideLoading()
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([csv], { type: 'text/csv;charset=utf-8;' })
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 = `小菜记账_${getLocalDateStr()}.csv`
a.download = fileName
a.click()
URL.revokeObjectURL(url)
uni.hideLoading()
@@ -339,59 +499,53 @@ async function exportData() {
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${getLocalDateStr()}.csv`
fs.writeFileSync(filePath, csv, 'utf-8')
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: 'csv',
fileType: ext,
success: () => {},
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
})
// #endif
} catch {
uni.hideLoading()
uni.showToast({ title: '导出失败', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
@include page-base;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar {
background: #FFF8F0;
@include status-bar;
}
.page-title {
display: block;
text-align: center;
font-size: 36rpx;
font-size: $font-2xl;
font-weight: 600;
color: #2D1B1B;
padding: 16rpx 0 24rpx;
color: $text;
padding: $space-sm 0 $space-md;
}
.profile-card {
margin: 0 40rpx;
padding: 48rpx;
background: linear-gradient(135deg, #FF8C69, #FFB89A);
border-radius: 40rpx;
margin: 0 $space-xl;
padding: $space-2xl;
background: linear-gradient(135deg, $primary, #FFB89A);
border-radius: $radius-2xl;
box-shadow: 8rpx 8rpx 24rpx rgba(45, 27, 27, 0.1);
display: flex;
align-items: center;
gap: 32rpx;
gap: $space-lg;
&:active { opacity: 0.85; }
}
@@ -408,72 +562,65 @@ async function exportData() {
}
.p-name {
font-size: 40rpx;
font-size: $font-3xl;
font-weight: 600;
color: #FFFFFF;
color: $surface;
display: block;
}
.p-tagline {
font-size: 28rpx;
font-size: $font-lg;
color: rgba(255, 255, 255, 0.8);
display: block;
margin-top: 4rpx;
}
.p-streak {
font-size: 24rpx;
font-size: $font-md;
color: rgba(255, 255, 255, 0.65);
display: block;
margin-top: 16rpx;
margin-top: $space-sm;
}
.quick-stats {
display: flex;
gap: 24rpx;
padding: 0 40rpx;
margin-top: 32rpx;
gap: $space-md;
padding: 0 $space-xl;
margin-top: $space-lg;
}
.qs-card {
flex: 1;
padding: 32rpx 24rpx;
background: #FFFFFF;
padding: $space-lg $space-md;
background: $surface;
border-radius: 32rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
border: 2rpx solid $border;
box-shadow: $shadow-md;
text-align: center;
}
.qs-label {
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: 8rpx;
margin-bottom: $space-xs;
}
.qs-value {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-size: $font-xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
&.income { color: #7BC67E; }
&.income { color: $success; }
}
.qs-skeleton {
width: 160rpx;
height: 40rpx;
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
background-size: 200% 100%;
border-radius: 8rpx;
@include shimmer;
border-radius: $radius-xs;
margin: 0 auto;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
@@ -483,98 +630,92 @@ async function exportData() {
}
.menu-card {
margin: 40rpx 40rpx 0;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin: $space-xl $space-xl 0;
background: $surface;
border-radius: $radius-2xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
overflow: hidden;
&.mt { margin-top: 32rpx; }
&.mt { margin-top: $space-lg; }
}
.menu-item {
height: 112rpx;
padding: 0 40rpx;
padding: 0 $space-xl;
display: flex;
align-items: center;
border-bottom: 1rpx solid #F0E0D6;
border-bottom: 1rpx solid $border;
&:last-child { border-bottom: none; }
&:active { background: #FFF8F0; }
&:active { background: $bg; }
}
.mi-label { flex: 1; font-size: 28rpx; font-weight: 500; color: #2D1B1B; }
.mi-extra { font-size: 28rpx; color: #FF8C69; font-weight: 600; margin-right: 16rpx; }
.mi-label { flex: 1; font-size: $font-lg; font-weight: 500; color: $text; }
.mi-extra { font-size: $font-lg; color: $primary; font-weight: 600; margin-right: $space-sm; }
.admin-entry {
gap: 16rpx;
.mi-label { color: #FF8C69; font-weight: 600; }
gap: $space-sm;
.mi-label { color: $primary; font-weight: 600; }
}
.about-title {
font-size: 40rpx;
font-size: $font-3xl;
font-weight: 600;
color: #FF8C69;
color: $primary;
display: block;
text-align: center;
}
.about-version {
font-size: 24rpx;
color: #BFB3B3;
font-size: $font-md;
color: $text-muted;
display: block;
text-align: center;
margin: 8rpx 0 24rpx;
margin: $space-xs 0 $space-md;
}
.about-desc {
font-size: 28rpx;
color: #8B7E7E;
font-size: $font-lg;
color: $text-sec;
display: block;
text-align: center;
margin-bottom: 8rpx;
margin-bottom: $space-xs;
}
.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: 999;
@include modal-mask-high;
}
.modal {
width: 600rpx;
background: #FFFFFF;
border-radius: 32rpx;
padding: 48rpx;
background: $surface;
border-radius: $space-lg;
padding: $space-2xl;
}
.modal-btns {
display: flex;
gap: 24rpx;
gap: $space-md;
}
.modal-btns-top {
margin-top: 40rpx;
margin-top: $space-xl;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: 24rpx;
border-radius: $space-md;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
&.confirm {
background: linear-gradient(135deg, #FF8C69, #E67355);
color: #FFFFFF;
background: linear-gradient(135deg, $primary, #E67355);
color: $surface;
}
}
@@ -585,17 +726,17 @@ async function exportData() {
}
.identity-card {
margin: 24rpx 40rpx 0;
padding: 28rpx 32rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin: $space-md $space-xl 0;
padding: $font-lg $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
display: flex;
align-items: center;
gap: 20rpx;
gap: $space-lg;
&:active { background: #FFF0E6; }
&:active { background: $surface-warm; }
}
.id-info {
@@ -605,14 +746,14 @@ async function exportData() {
}
.id-name {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
color: #2D1B1B;
color: $text;
}
.id-desc {
font-size: 24rpx;
color: #BFB3B3;
font-size: $font-md;
color: $text-muted;
margin-top: 4rpx;
}
@@ -621,33 +762,33 @@ async function exportData() {
bottom: 0;
left: 0;
right: 0;
background: #FFFFFF;
border-radius: 40rpx 40rpx 0 0;
padding: 40rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
padding: $space-xl;
padding-bottom: calc($space-xl + env(safe-area-inset-bottom));
max-height: 70vh;
overflow-y: auto;
}
.modal-title {
font-size: 32rpx;
font-size: $font-xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
display: block;
text-align: center;
margin-bottom: 32rpx;
margin-bottom: $space-lg;
}
.id-option {
display: flex;
align-items: center;
gap: 20rpx;
padding: 28rpx 24rpx;
border-radius: 20rpx;
gap: $space-lg;
padding: $font-lg $space-md;
border-radius: $radius-lg;
margin-bottom: 12rpx;
&.active { background: #FFF0E6; }
&:active { background: #FFF8F0; }
&.active { background: $surface-warm; }
&:active { background: $bg; }
}
.id-option-info {
@@ -657,53 +798,171 @@ async function exportData() {
}
.id-option-name {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 500;
color: #2D1B1B;
color: $text;
}
.id-option-desc {
font-size: 24rpx;
color: #BFB3B3;
font-size: $font-md;
color: $text-muted;
margin-top: 2rpx;
}
.id-actions {
display: flex;
gap: 24rpx;
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #F0E0D6;
gap: $space-md;
margin-top: $space-md;
padding-top: $space-md;
border-top: 1rpx solid $border;
}
.id-action-btn {
flex: 1;
height: 80rpx;
border-radius: 20rpx;
background: #FFF0E6;
border-radius: $radius-lg;
background: $surface-warm;
display: flex;
align-items: center;
justify-content: center;
&:active { background: #FFE8D6; }
&:active { background: darken($surface-warm, 3%); }
}
.id-action-text {
font-size: 28rpx;
font-size: $font-lg;
font-weight: 500;
color: #FF8C69;
color: $primary;
}
.id-manage {
margin-top: 16rpx;
padding: 24rpx 20rpx;
margin-top: $space-sm;
padding: $space-md $space-lg;
text-align: center;
&:active { background: #FFF0E6; border-radius: 16rpx; }
&:active { background: $surface-warm; border-radius: $radius-md; }
}
.id-manage-text {
font-size: 26rpx;
color: #8B7E7E;
font-size: $font-base;
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;
}
</style>

View File

@@ -0,0 +1,515 @@
<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="loading-box">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="list.length === 0" class="empty-box">
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="empty-text">暂无周期账单</text>
<text class="empty-hint">设置房租订阅工资等定期收支</text>
<view class="empty-btn" @tap="openAddModal">
<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 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 () => {
await waitForReady()
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; }
.loading-box, .empty-box {
display: flex; flex-direction: column; align-items: center;
padding: 120rpx 0; gap: $space-sm;
}
.loading-text, .empty-text { font-size: $font-lg; color: $text-muted; }
.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: $primary; border-radius: $radius-lg;
&:active { opacity: 0.8; }
}
.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

@@ -120,9 +120,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { onShow, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { useStatsStore } from '@/stores/stats'
import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction'
import { getOverview } from '@/api/stats'
@@ -134,6 +135,7 @@ import ChartWrapper from '@/components/ChartWrapper/ChartWrapper.vue'
const statsStore = useStatsStore()
const groupStore = useGroupStore()
const configStore = useConfigStore()
const currentMonth = ref(getCurrentMonth())
const tabType = ref<'expense' | 'income'>('expense')
@@ -235,9 +237,10 @@ onShow(() => {
if (initialLoaded.value) loadData(true)
})
// Only re-fetch category/trend on tab switch; re-fetch all on month change
// 月份变化时重新请求所有数据
watch(currentMonth, () => loadData())
watch(tabType, () => loadData(true))
// tab 切换时只请求分类和趋势数据
watch(tabType, () => loadTabData())
async function loadData(silent = false) {
const seq = ++loadSeq
@@ -261,6 +264,22 @@ async function loadData(silent = false) {
}
}
// tab 切换时只加载分类和趋势数据(不重复请求上月对比和最大单笔)
async function loadTabData() {
const seq = ++loadSeq
try {
await Promise.all([
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value),
fetchMaxSingle()
])
if (seq !== loadSeq) return
} catch (e) {
if (seq !== loadSeq) return
console.error('Stats tab load error:', e)
}
}
async function fetchMaxSingle() {
try {
const [y, m] = currentMonth.value.split('-').map(Number)
@@ -295,6 +314,20 @@ onPullDownRefresh(() => {
loadData().finally(() => uni.stopPullDownRefresh())
})
// #ifdef MP-WEIXIN
onShareAppMessage(() => {
const expense = formatAmount(overview.value.expense)
return {
title: `我的${formatMonth(currentMonth.value)}账单:支出 ${expense}`,
path: '/pages/stats/index'
}
})
onShareTimeline(() => ({
title: `${configStore.appName} — 我的${formatMonth(currentMonth.value)}账单`
}))
// #endif
function prevMonth() {
const [y, m] = currentMonth.value.split('-').map(Number)
currentMonth.value = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}`
@@ -313,46 +346,36 @@ function getBarWidth(amount: number) {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
@include page-base;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar {
background: #FFF8F0;
@include status-bar;
}
.page-title {
display: block;
text-align: center;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
padding: 16rpx 0 24rpx;
@include page-title;
}
.month-picker {
display: flex;
align-items: center;
justify-content: center;
gap: 48rpx;
padding: 16rpx 0;
gap: $space-2xl;
padding: $space-sm 0;
}
.arrow {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
@include flex-center;
&.disabled { opacity: 0.5; }
@@ -361,42 +384,35 @@ function getBarWidth(amount: number) {
.month-text {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-size: $font-xl;
font-weight: 500;
color: #2D1B1B;
color: $text;
}
.tabs-wrap { display: flex; justify-content: center; margin: 16rpx 0 32rpx; }
.tabs-wrap {
@include tabs-wrap;
margin: $space-sm 0 $space-lg;
}
.tabs {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
@include tabs;
border-radius: $radius-xl;
}
.tab {
padding: 16rpx 40rpx;
border-radius: 20rpx;
font-size: 28rpx;
color: #8B7E7E;
transition: all 0.2s;
@include tab;
border-radius: $radius-xl;
&.active {
background: #FFFFFF;
color: #FF8C69;
font-weight: 600;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
border-radius: $radius-lg;
box-shadow: $shadow-md;
}
}
.overview-card, .chart-card {
margin: 0 40rpx 32rpx;
padding: 40rpx;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
@include card($radius-2xl);
margin: 0 $space-xl $space-lg;
padding: $space-xl;
}
.overview-card {
@@ -407,64 +423,61 @@ function getBarWidth(amount: number) {
.extra-stats {
display: flex;
gap: 24rpx;
margin: 0 40rpx 32rpx;
gap: $space-md;
margin: 0 $space-xl $space-lg;
}
.es-item {
flex: 1;
padding: 24rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
@include card($radius-xl);
padding: $space-md;
text-align: center;
}
.es-label {
font-size: 22rpx;
color: #8B7E7E;
font-size: $font-sm;
color: $text-sec;
display: block;
margin-bottom: 8rpx;
margin-bottom: $space-xs;
}
.es-value {
font-family: 'Fredoka', sans-serif;
font-size: 28rpx;
font-size: $font-lg;
font-weight: 600;
color: #2D1B1B;
color: $text;
&.up { color: #FF6B6B; }
&.down { color: #7BC67E; }
&.same { color: #8B7E7E; }
&.up { color: $danger; }
&.down { color: $success; }
&.same { color: $text-sec; }
}
.ov-label {
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: 12rpx;
}
.ov-value {
font-family: 'Fredoka', sans-serif;
font-size: 40rpx;
font-size: $font-3xl;
font-weight: 600;
color: #2D1B1B;
color: $text;
}
.chart-title {
font-size: 32rpx;
font-size: $font-xl;
font-weight: 500;
color: #2D1B1B;
color: $text;
display: block;
margin-bottom: 24rpx;
margin-bottom: $space-md;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 16rpx 32rpx;
gap: $space-sm $space-lg;
}
.legend-item {
@@ -479,74 +492,73 @@ function getBarWidth(amount: number) {
border-radius: 50%;
}
.legend-text { font-size: 24rpx; color: #8B7E7E; }
.legend-text { font-size: $font-md; color: $text-sec; }
.empty {
text-align: center;
padding: 80rpx 0;
}
.empty-text { font-size: 28rpx; color: #BFB3B3; }
.empty-text { font-size: $font-lg; color: $text-muted; }
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
@include state-box;
padding: 80rpx 0;
}
.state-text {
font-size: 28rpx;
color: #BFB3B3;
@include state-text;
}
.trend-list { display: flex; flex-direction: column; gap: 16rpx; }
.trend-list { display: flex; flex-direction: column; gap: $space-sm; }
.skeleton-legend {
display: flex;
flex-wrap: wrap;
gap: 16rpx 32rpx;
gap: $space-sm $space-lg;
}
.skeleton-trend {
display: flex;
align-items: center;
gap: 16rpx;
gap: $space-sm;
}
.trend-item {
display: flex;
align-items: center;
gap: 16rpx;
gap: $space-sm;
}
.trend-date {
font-size: 24rpx;
color: #8B7E7E;
font-size: $font-md;
color: $text-sec;
}
.trend-bar-bg {
flex: 1;
height: 12rpx;
border-radius: 6rpx;
background: #F0E0D6;
background: $border;
overflow: hidden;
}
.trend-bar {
height: 100%;
border-radius: 6rpx;
background: linear-gradient(90deg, #FF8C69, #FFB89A);
background: linear-gradient(90deg, $primary, #FFB89A);
transition: width 0.6s ease-out;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
.trend-amount {
width: 120rpx;
font-family: 'Fredoka', sans-serif;
font-size: 24rpx;
font-size: $font-md;
font-weight: 500;
color: #2D1B1B;
color: $text;
}
</style>

View File

@@ -38,6 +38,7 @@ export const useCategoryStore = defineStore('category', () => {
async function migrateCategory(fromId: number, toId: number) {
await api.migrateCategory(fromId, toId)
await fetchCategories()
}
async function sortCategories(ids: number[]) {

View File

@@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getConfig } from '@/api/config'
/** 系统配置 store全局缓存品牌文案等配置 */
export const useConfigStore = defineStore('config', () => {
const config = ref<Record<string, string>>({})
const loaded = ref(false)
/** 品牌名称(后端返回前为空) */
const appName = computed(() => config.value.app_name || '')
/** 品牌标语 */
const appSlogan = computed(() => config.value.app_slogan || '')
/** 品牌描述 */
const appDescription = computed(() => config.value.app_description || '')
async function fetchConfig() {
try {
const data = await getConfig()
config.value = data
loaded.value = true
} catch {
// 配置加载失败
}
}
return { config, loaded, appName, appSlogan, appDescription, fetchConfig }
})

View File

@@ -22,27 +22,27 @@ export const useGroupStore = defineStore('group', () => {
const isGroupMode = computed(() => currentGroupId.value !== null)
/** 切换到个人模式 */
function switchToPersonal() {
async function switchToPersonal() {
currentGroupId.value = null
uni.removeStorageSync('xc:currentGroupId')
refreshAll()
await refreshAll()
}
/** 切换到群组模式 */
function switchToGroup(groupId: number) {
async function switchToGroup(groupId: number) {
currentGroupId.value = groupId
uni.setStorageSync('xc:currentGroupId', groupId)
refreshAll()
await refreshAll()
}
/** 切换身份后全局刷新所有数据 */
function refreshAll() {
/** 切换身份后全局刷新所有数据(串行执行避免数据错乱) */
async function refreshAll() {
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const txStore = useTransactionStore()
statsStore.fetchOverview()
budgetStore.fetchBudget()
txStore.fetchTransactions({ page: 1 })
await statsStore.fetchOverview()
await budgetStore.fetchBudget()
await txStore.fetchTransactions({ page: 1 })
}
/** 获取群组列表 */
@@ -55,7 +55,7 @@ export const useGroupStore = defineStore('group', () => {
if (currentGroupId.value !== null) {
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
if (!stillValid) {
switchToPersonal()
await switchToPersonal()
}
}
} catch {
@@ -83,7 +83,7 @@ export const useGroupStore = defineStore('group', () => {
async function leaveGroup(groupId: number) {
await api.leaveGroup(groupId)
if (currentGroupId.value === groupId) {
switchToPersonal()
await switchToPersonal()
}
await fetchGroups()
}
@@ -92,7 +92,7 @@ export const useGroupStore = defineStore('group', () => {
async function deleteGroup(groupId: number) {
await api.deleteGroup(groupId)
if (currentGroupId.value === groupId) {
switchToPersonal()
await switchToPersonal()
}
await fetchGroups()
}
@@ -127,7 +127,8 @@ export const useGroupStore = defineStore('group', () => {
function init() {
const saved = uni.getStorageSync('xc:currentGroupId')
if (saved) {
currentGroupId.value = saved
// 确保类型为数字(某些平台可能返回字符串)
currentGroupId.value = typeof saved === 'string' ? Number(saved) : saved
}
}

View File

@@ -8,26 +8,42 @@ export const useNotificationStore = defineStore('notification', () => {
const urgentNotifications = ref<Notification[]>([])
async function fetchUnreadCount() {
try {
const data = await api.getUnreadCount()
unreadCount.value = data.count
} catch {
// 静默失败,不影响页面
}
}
/** 获取未读的强提醒公告(首页弹窗用) */
async function fetchUrgentNotifications() {
try {
urgentNotifications.value = await api.getPinnedNotifications()
} catch {
// 静默失败
}
}
async function markRead(id: number) {
try {
await api.markRead(id)
if (unreadCount.value > 0) unreadCount.value--
// 从强提醒列表中移除
urgentNotifications.value = urgentNotifications.value.filter(n => n.id !== id)
} catch {
// 静默失败
}
}
async function markAllRead() {
try {
await api.markAllRead()
unreadCount.value = 0
urgentNotifications.value = []
} catch {
// 静默失败
}
}
return { unreadCount, urgentNotifications, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead }

View File

@@ -10,6 +10,10 @@ export const useUserStore = defineStore('user', () => {
return userInfo.value?.nickname || uni.getStorageSync('xc:nickname') || '小菜'
})
const slogan = computed(() => {
return userInfo.value?.slogan
})
const avatarUrl = computed(() => {
const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || ''
if (!filename) return ''
@@ -28,10 +32,17 @@ export const useUserStore = defineStore('user', () => {
}
}
async function updateProfile(nickname: string) {
await api.updateNickname(nickname)
if (userInfo.value) userInfo.value.nickname = nickname
uni.setStorageSync('xc:nickname', nickname)
async function updateProfile(data: { nickname?: string; slogan?: string }) {
const nickname = data.nickname ?? userInfo.value?.nickname ?? ''
const slogan = data.slogan ?? userInfo.value?.slogan ?? ''
await api.updateNickname(nickname, slogan)
if (userInfo.value) {
if (data.nickname !== undefined) userInfo.value.nickname = data.nickname
if (data.slogan !== undefined) userInfo.value.slogan = data.slogan
}
if (data.nickname !== undefined) {
uni.setStorageSync('xc:nickname', data.nickname)
}
}
async function uploadAvatar(filePath: string) {
@@ -41,5 +52,5 @@ export const useUserStore = defineStore('user', () => {
return avatarUrl.value
}
return { userInfo, nickname, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar }
return { userInfo, nickname, slogan, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar }
})

View File

@@ -0,0 +1,249 @@
/* ===== 公共 Mixins ===== */
// 页面基础样式
@mixin page-base {
min-height: 100vh;
background: $bg;
padding-bottom: 180rpx;
}
// 粘性头部
@mixin sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: $bg;
}
// 状态栏
@mixin status-bar {
background: $bg;
}
// 卡片
@mixin card($radius: $radius-2xl) {
background: $surface;
border-radius: $radius;
border: 2rpx solid $border;
box-shadow: $shadow-md;
}
// 导航栏
@mixin nav-bar {
display: flex;
align-items: center;
padding: $space-sm $space-xl $space-md;
}
// 返回按钮
@mixin nav-back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.6; }
}
// 页面标题
@mixin page-title {
display: block;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
padding: $space-sm 0 $space-md;
}
// 状态空/错误/加载
@mixin state-box {
display: flex;
flex-direction: column;
align-items: center;
gap: $space-sm;
padding: 120rpx 0;
}
@mixin state-text {
font-size: $font-lg;
color: $text-muted;
}
// Tabs
@mixin tabs-wrap {
display: flex;
align-items: center;
justify-content: center;
gap: $space-sm;
}
@mixin tabs {
display: inline-flex;
background: $surface-warm;
border-radius: $radius-lg;
padding: $space-xs;
}
@mixin tab {
padding: $space-sm $space-xl;
border-radius: $radius-lg;
font-size: $font-lg;
color: $text-sec;
transition: all $transition-normal;
&.active {
background: $surface;
color: $primary;
font-weight: 600;
box-shadow: $shadow-sm;
}
}
// 弹窗
@mixin modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
display: flex;
align-items: flex-end;
}
// 高层级弹窗(紧急公告、身份选择等需要覆盖其他弹窗的场景)
@mixin modal-mask-high {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
}
@mixin bottom-modal {
width: 100%;
max-height: 80vh;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
}
@mixin modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
@mixin modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
}
@mixin modal-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $border;
&:active { background: darken($border, 5%); }
}
// 列表项
@mixin list-item {
display: flex;
align-items: center;
gap: $space-md;
padding: $space-lg $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
box-shadow: $shadow-md;
margin-bottom: $space-sm;
}
// 输入框
@mixin input {
width: 100%;
height: 80rpx;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-lg;
color: $text;
}
// 按钮
@mixin btn-primary {
height: 80rpx;
padding: 0 $space-lg;
background: linear-gradient(135deg, $primary, $primary-dark);
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
&:active { opacity: 0.8; }
}
@mixin btn-primary-text {
font-size: $font-lg;
font-weight: 600;
color: $surface;
white-space: nowrap;
}
@mixin btn-ghost {
padding: $space-sm $space-md;
background: $surface-warm;
border-radius: $radius-md;
&:active { background: $border; }
}
@mixin btn-ghost-text {
font-size: $font-md;
color: $primary;
}
// Shimmer 动画
@mixin shimmer {
background: linear-gradient(90deg, $border 25%, $surface-warm 50%, $border 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// Flex 工具
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
// 文本溢出
@mixin text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 安全区域
@mixin safe-bottom {
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}

View File

@@ -1,31 +1,69 @@
/* Design Tokens */
/* ===== Design Tokens ===== */
// 颜色系统
$primary: #FF8C69;
$primary-light: #FFB89A;
$primary-light: #FFE8E0;
$primary-dark: #E67355;
$secondary: #6C9BCF;
$accent: #FFD166;
$bg: #FFF8F0;
$surface: #FFFFFF;
$surface-warm: #FFF0E6;
$text: #2D1B1B;
$text-sec: #8B7E7E;
$text-muted: #BFB3B3;
$success: #7BC67E;
$warning: #FFB347;
$danger: #FF6B6B;
$danger-light: #FFE8E8;
$border: #F0E0D6;
$shadow-clay: 4px 4px 12px rgba(45, 27, 27, 0.08), inset -2px -2px 6px rgba(45, 27, 27, 0.04);
$shadow-clay-hover: 6px 6px 16px rgba(45, 27, 27, 0.12), inset -2px -2px 6px rgba(45, 27, 27, 0.04);
$shadow-clay-pressed: inset 2px 2px 6px rgba(45, 27, 27, 0.1);
// 间距系统
$space-xs: 8rpx;
$space-sm: 16rpx;
$space-md: 24rpx;
$space-lg: 32rpx;
$space-xl: 40rpx;
$space-2xl: 48rpx;
$radius-sm: 8rpx;
$radius-md: 12rpx;
$radius-lg: 16rpx;
$radius-xl: 20rpx;
// 圆角系统
$radius-xs: 8rpx;
$radius-sm: 12rpx;
$radius-md: 16rpx;
$radius-lg: 20rpx;
$radius-xl: 28rpx;
$radius-2xl: 40rpx;
$radius-full: 9999rpx;
/* uCharts theme */
// 字体系统
$font-xs: 20rpx;
$font-sm: 22rpx;
$font-md: 24rpx;
$font-base: 26rpx;
$font-lg: 28rpx;
$font-xl: 32rpx;
$font-2xl: 36rpx;
$font-3xl: 40rpx;
$font-4xl: 56rpx;
// 阴影系统
$shadow-sm: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
$shadow-md: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
$shadow-lg: 8rpx 8rpx 24rpx rgba(45, 27, 27, 0.12);
$shadow-clay: $shadow-md, inset -2rpx -2rpx 6rpx rgba(45, 27, 27, 0.04);
$shadow-clay-hover: 6rpx 6rpx 16rpx rgba(45, 27, 27, 0.12), inset -2rpx -2rpx 6rpx rgba(45, 27, 27, 0.04);
$shadow-clay-pressed: inset 2rpx 2rpx 6rpx rgba(45, 27, 27, 0.1);
// 动画
$transition-fast: 0.15s ease;
$transition-normal: 0.2s ease;
$transition-slow: 0.3s ease;
/* ===== uCharts theme ===== */
page {
--u-charts-color-0: #FF8C69;
--u-charts-color-1: #FFD166;

View File

@@ -11,10 +11,10 @@ const _ready = new Promise<void>(resolve => { _resolve = resolve })
/** 标记 App 启动完成(登录成功) */
export function markReady() { _resolve() }
/** 等待 App 就绪,最多等 3 秒超时 */
/** 等待 App 就绪,最多等 8 秒超时(小程序登录可能较慢) */
export function waitForReady(): Promise<void> {
return Promise.race([
_ready,
new Promise<void>(resolve => setTimeout(resolve, 3000))
new Promise<void>(resolve => setTimeout(resolve, 8000))
])
}

View File

@@ -36,7 +36,11 @@ export function formatDate(date: string | Date): string {
if (diff === 1) 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 {
@@ -44,9 +48,12 @@ export function formatMonth(date: string): string {
return `${year}${parseInt(month)}`
}
/** 获取当前月份UTC+8 时区,与服务端保持一致) */
export function getCurrentMonth(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
const offset = 8 * 60 * 60 * 1000
const local = new Date(d.getTime() + offset)
return local.toISOString().slice(0, 7)
}
export function formatAmountRaw(fen: number): string {

View File

@@ -1,4 +1,6 @@
import { API_BASE } from '@/config'
import type { LoginResult } from '@/api/auth'
import { trackApiError } from './tracker'
interface RequestOptions {
url: string
@@ -6,14 +8,6 @@ interface RequestOptions {
data?: any
}
interface LoginResult {
token: string
userId: number
nickname: string
avatar_url: string
role?: string
}
let isRedirectingToLogin = false
/** 重登录期间挂起的请求,登录成功后统一重试 */
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = []
@@ -53,6 +47,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
url: API_BASE + options.url,
method: options.method || 'GET',
data: options.data,
timeout: 15000, // 15秒超时
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
@@ -63,6 +58,10 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
if (statusCode === 401) {
uni.removeStorageSync('xc:token')
uni.removeStorageSync('xc:userId')
uni.removeStorageSync('xc:nickname')
uni.removeStorageSync('xc:avatar_url')
uni.removeStorageSync('xc:role')
uni.removeStorageSync('xc:currentGroupId')
// 加入重试队列
pendingRetries.push({ resolve, reject, options })
@@ -91,6 +90,8 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
if (data.code === 0) {
resolve(data.data)
} else {
// 埋点API 业务错误
trackApiError(options.url, statusCode, data.message || '请求失败')
// "用户不存在" 不弹 toast首次登录竞态
if (data.code !== 40400 || data.message !== '用户不存在') {
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
@@ -99,6 +100,8 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
}
},
fail: (err: any) => {
// 埋点:网络错误
trackApiError(options.url, 0, '网络错误')
uni.showToast({ title: '网络错误', icon: 'none' })
reject(err)
}

140
client/src/utils/tracker.ts Normal file
View File

@@ -0,0 +1,140 @@
/**
* 前端埋点工具
* 记录用户关键操作和错误信息
*/
import { request } from './request'
interface TrackEvent {
event: string
page?: string
data?: Record<string, any>
}
/** 上报队列(批量上报,减少请求) */
let eventQueue: TrackEvent[] = []
let flushTimer: number | null = null
const FLUSH_INTERVAL = 5000 // 5秒上报一次
const MAX_QUEUE_SIZE = 20
/** 获取当前页面路径 */
function getCurrentPage(): string {
const pages = getCurrentPages()
if (pages.length > 0) {
return `/${pages[pages.length - 1].route}`
}
return '/unknown'
}
/** 批量上报事件 */
async function flushEvents() {
if (eventQueue.length === 0) return
const events = [...eventQueue]
eventQueue = []
try {
await request({
url: '/track',
method: 'POST',
data: { events }
})
} catch {
// 上报失败不处理,避免影响用户体验
}
}
/** 添加事件到队列 */
function addToQueue(event: TrackEvent) {
eventQueue.push(event)
// 队列满时立即上报
if (eventQueue.length >= MAX_QUEUE_SIZE) {
flushEvents()
return
}
// 设置定时上报
if (!flushTimer) {
flushTimer = setTimeout(() => {
flushTimer = null
flushEvents()
}, FLUSH_INTERVAL) as unknown as number
}
}
/**
* 追踪页面访问
*/
export function trackPageView(pagePath?: string) {
addToQueue({
event: 'page_view',
page: pagePath || getCurrentPage()
})
}
/**
* 追踪用户操作
* @param action 操作名称,如 'click_save', 'submit_form'
* @param data 附加数据
*/
export function trackAction(action: string, data?: Record<string, any>) {
addToQueue({
event: 'action',
page: getCurrentPage(),
data: { action, ...data }
})
}
/**
* 追踪错误
*/
export function trackError(error: Error | string, extra?: Record<string, any>) {
const errorInfo = typeof error === 'string'
? { message: error }
: { message: error.message, stack: error.stack?.slice(0, 500) }
addToQueue({
event: 'error',
page: getCurrentPage(),
data: { ...errorInfo, ...extra }
})
}
/**
* 追踪 API 请求失败
*/
export function trackApiError(url: string, status: number, message: string) {
addToQueue({
event: 'api_error',
page: getCurrentPage(),
data: { url, status, message }
})
}
/**
* 设置全局错误监听
*/
export function setupErrorTracking() {
// Vue 错误处理
uni.onError((err: string) => {
trackError(err, { type: 'global' })
})
// 未处理的 Promise rejection
// #ifdef H5
if (typeof window !== 'undefined') {
window.addEventListener('unhandledrejection', (event) => {
trackError(String(event.reason), { type: 'unhandledrejection' })
})
}
// #endif
}
export default {
trackPageView,
trackAction,
trackError,
trackApiError,
setupErrorTracking
}

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

@@ -148,6 +148,135 @@ async function runMigrations(conn: mysql.Connection) {
await conn.query('ALTER TABLE notifications ADD INDEX idx_pinned (is_pinned, created_at)')
await conn.query('ALTER TABLE notifications ADD INDEX idx_expire (expire_at)')
}
// 公告已读记录表(解决系统/群组公告已读状态共享问题)
const hasNotificationReads = await tableExists(conn, 'notification_reads')
if (!hasNotificationReads) {
console.log('[DB] Migrating: creating notification_reads table')
await conn.query(`
CREATE TABLE IF NOT EXISTS notification_reads (
notification_id INT NOT NULL,
user_id INT NOT NULL,
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (notification_id, user_id),
INDEX idx_user (user_id),
FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// users 表添加 slogan个性签名
const hasSlogan = await columnExists(conn, 'users', 'slogan')
if (!hasSlogan) {
console.log('[DB] Migrating: adding slogan to users')
await conn.query("ALTER TABLE users ADD COLUMN slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名' AFTER avatar_url")
}
// 系统配置表
const hasSysConfig = await tableExists(conn, 'sys_config')
if (!hasSysConfig) {
console.log('[DB] Migrating: creating sys_config table')
await conn.query(`
CREATE TABLE IF NOT EXISTS sys_config (
config_key VARCHAR(50) PRIMARY KEY COMMENT '配置键',
config_value TEXT NOT NULL COMMENT '配置值',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
// 插入默认配置
await conn.query(`
INSERT IGNORE INTO sys_config (config_key, config_value) VALUES
('app_name', '小菜记账'),
('app_version', '1.0.0'),
('app_slogan', '温暖 x 轻松的个人记账小程序'),
('app_description', '让记账从负担变成享受')
`)
}
// 确保 app_name 配置存在(兼容已有数据库)
const [appNameRows] = await conn.query(
"SELECT COUNT(*) as cnt FROM sys_config WHERE config_key = 'app_name'"
)
if ((appNameRows as any[])[0].cnt === 0) {
await conn.query("INSERT INTO sys_config (config_key, config_value) VALUES ('app_name', '小菜记账')")
}
// 反馈表
const hasFeedbacks = await tableExists(conn, 'feedbacks')
if (!hasFeedbacks) {
console.log('[DB] Migrating: creating feedbacks table')
await conn.query(`
CREATE TABLE IF NOT EXISTS feedbacks (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
type ENUM('bug', 'feature', 'ux', 'other') NOT NULL DEFAULT 'other' COMMENT '反馈类型',
content TEXT NOT NULL COMMENT '反馈内容',
contact VARCHAR(100) DEFAULT '' COMMENT '联系方式',
status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态',
admin_reply TEXT COMMENT '管理员回复',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_status (status),
INDEX idx_created (created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// 埋点事件表
const hasTrackEvents = await tableExists(conn, 'track_events')
if (!hasTrackEvents) {
console.log('[DB] Migrating: creating track_events table')
await conn.query(`
CREATE TABLE IF NOT EXISTS track_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event VARCHAR(50) NOT NULL COMMENT '事件类型',
page VARCHAR(100) DEFAULT '' COMMENT '页面路径',
data JSON DEFAULT NULL COMMENT '事件数据',
user_id INT DEFAULT NULL COMMENT '用户ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_event (event),
INDEX idx_created (created_at),
INDEX idx_user (user_id)
) 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")
}
}
export async function initDatabase() {

View File

@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users (
session_key VARCHAR(100),
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名',
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
@@ -31,7 +32,8 @@ CREATE TABLE IF NOT EXISTS transactions (
category_id INT,
note VARCHAR(200),
date DATE NOT NULL,
group_id INT DEFAULT NULL COMMENT '群组标签NULL=纯个人记录',
group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)',
recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_date (user_id, date),
@@ -81,7 +83,7 @@ CREATE TABLE IF NOT EXISTS notifications (
type ENUM('system', 'group', 'personal') NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT,
is_read TINYINT(1) DEFAULT 0,
is_read TINYINT(1) DEFAULT 0 COMMENT '仅用于个人通知,系统/群组公告使用 notification_reads 表',
is_pinned TINYINT(1) DEFAULT 0 COMMENT '是否置顶',
is_urgent TINYINT(1) DEFAULT 0 COMMENT '是否强提醒弹窗',
publish_at TIMESTAMP NULL COMMENT '定时发布时间NULL=立即发布)',
@@ -95,6 +97,17 @@ CREATE TABLE IF NOT EXISTS notifications (
INDEX idx_expire (expire_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 公告已读记录表(解决系统/群组公告已读状态共享问题)
CREATE TABLE IF NOT EXISTS notification_reads (
notification_id INT NOT NULL,
user_id INT NOT NULL,
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (notification_id, user_id),
INDEX idx_user (user_id),
FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS saved_filters (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
@@ -103,3 +116,57 @@ CREATE TABLE IF NOT EXISTS saved_filters (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS feedbacks (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
type ENUM('bug', 'feature', 'ux', 'other') NOT NULL DEFAULT 'other' COMMENT '反馈类型',
content TEXT NOT NULL COMMENT '反馈内容',
contact VARCHAR(100) DEFAULT '' COMMENT '联系方式',
status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态',
admin_reply TEXT COMMENT '管理员回复',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_status (status),
INDEX idx_created (created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sys_config (
config_key VARCHAR(50) PRIMARY KEY COMMENT '配置键',
config_value TEXT NOT NULL COMMENT '配置值',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS track_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event VARCHAR(50) NOT NULL COMMENT '事件类型',
page VARCHAR(100) DEFAULT '' COMMENT '页面路径',
data JSON DEFAULT NULL COMMENT '事件数据',
user_id INT DEFAULT NULL COMMENT '用户ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_event (event),
INDEX idx_created (created_at),
INDEX idx_user (user_id)
) 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;

View File

@@ -15,3 +15,10 @@ INSERT IGNORE INTO categories (user_id, name, icon, color, type, sort_order) VAL
(0, '副业', 'TrendingUp', '#7BC67E', 'income', 2),
(0, '理财', 'PieChart', '#7BC67E', 'income', 3),
(0, '其他收入', 'Plus', '#BFB3B3', 'income', 4);
-- 系统配置
INSERT IGNORE INTO sys_config (config_key, config_value) VALUES
('app_name', '小菜记账'),
('app_version', '1.0.0'),
('app_slogan', '温暖 x 轻松的个人记账小程序'),
('app_description', '让记账从负担变成享受');

View File

@@ -5,6 +5,7 @@ import rateLimit from 'express-rate-limit'
import path from 'path'
import fs from 'fs'
import { authMiddleware } from './middleware/auth'
import { requestLogger } from './middleware/logger'
import { initDatabase } from './db/init'
import pool from './db/connection'
import authRoutes from './routes/auth'
@@ -18,6 +19,11 @@ import groupRoutes from './routes/group'
import notificationRoutes from './routes/notification'
import filterRoutes from './routes/filter'
import adminRoutes from './routes/admin'
import feedbackRoutes from './routes/feedback'
import configRoutes from './routes/config'
import trackRoutes from './routes/track'
import logsRoutes from './routes/logs'
import recurringRoutes from './routes/recurring'
import { backupDatabase } from './utils/backup'
// Warn if token secret is using default fallback
@@ -46,6 +52,9 @@ app.use(cors({
}))
app.use(express.json({ limit: '10kb' }))
// 请求日志
app.use(requestLogger)
// 限流:登录接口 — 每分钟最多 10 次
const authLimiter = rateLimit({
windowMs: 60 * 1000,
@@ -100,6 +109,42 @@ app.get('/api/user/avatar/:filename', async (req, res) => {
}
})
// 通知图片 API公开不经过 auth 中间件)
app.get('/api/notifications/image/:filename', async (req, res) => {
try {
const uploadDir = path.resolve(process.env.UPLOAD_DIR || './uploads')
const notifDir = path.join(uploadDir, 'notifications')
const filename = path.basename(req.params.filename)
// 文件名格式校验:允许 notif_用户ID_数字.ext
if (!/^notif_\d+_\d+\.(jpg|jpeg|png|webp)$/i.test(filename)) {
return res.status(400).json({ code: 40001, message: '无效文件名' })
}
const filepath = path.join(notifDir, filename)
// 路径穿越检查
if (!path.resolve(filepath).startsWith(path.resolve(notifDir))) {
return res.status(400).json({ code: 40001, message: '无效路径' })
}
if (!fs.existsSync(filepath)) {
return res.status(404).json({ code: 40400, message: '图片不存在' })
}
const ext = path.extname(filename).toLowerCase()
const mimeMap: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.webp': 'image/webp'
}
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream')
res.setHeader('Cache-Control', 'public, max-age=86400')
fs.createReadStream(filepath).pipe(res)
} catch {
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
app.use(authMiddleware)
app.use('/api/auth', authLimiter, authRoutes)
@@ -113,6 +158,11 @@ app.use('/api/groups', apiLimiter, groupRoutes)
app.use('/api/notifications', apiLimiter, notificationRoutes)
app.use('/api/filters', apiLimiter, filterRoutes)
app.use('/api/admin', apiLimiter, adminRoutes)
app.use('/api/feedback', apiLimiter, feedbackRoutes)
app.use('/api/config', apiLimiter, configRoutes)
app.use('/api/track', apiLimiter, trackRoutes)
app.use('/api/logs', apiLimiter, logsRoutes)
app.use('/api/recurring', apiLimiter, recurringRoutes)
app.get('/api/health', async (_req, res) => {
try {
@@ -123,6 +173,12 @@ app.get('/api/health', async (_req, res) => {
}
})
// 全局错误处理中间件(兜底未捕获的异常)
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('[Global] Unhandled error:', err)
res.status(500).json({ code: 50000, message: '服务器内部错误' })
})
let server: any
// 生产环境:先备份再迁移,迁移出问题可恢复

View File

@@ -0,0 +1,85 @@
import { Request, Response, NextFunction } from 'express'
import fs from 'fs'
import path from 'path'
// 日志目录
const LOG_DIR = path.resolve(process.env.LOG_DIR || './logs')
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true })
}
/** 获取当前日期字符串 YYYY-MM-DD服务器本地时间 */
function getDateStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 格式化时间 HH:mm:ss.SSS服务器本地时间 */
function formatTime(date: Date): string {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}`
}
/** 写入日志文件 */
function writeLog(level: string, message: string, meta?: Record<string, any>) {
const dateStr = getDateStr()
const logFile = path.join(LOG_DIR, `${dateStr}.log`)
const now = new Date()
const time = formatTime(now)
const timestamp = now.toISOString() // 记录 UTC 时间戳,前端可按用户时区显示
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const line = `[${time}] [${timestamp}] ${level} ${message}${metaStr}\n`
fs.appendFile(logFile, line, (err) => {
if (err) console.error('[Logger] Write error:', err)
})
}
/** 请求日志中间件 */
export function requestLogger(req: Request, res: Response, next: NextFunction) {
const startTime = Date.now()
const { method, url, ip } = req
// 响应结束时记录日志(此时 authMiddleware 已执行userId 可用)
res.on('finish', () => {
const duration = Date.now() - startTime
const { statusCode } = res
// 延迟获取 userId确保 authMiddleware 已执行
const userId = (req as any).userId
const meta = {
method,
url,
status: statusCode,
duration: `${duration}ms`,
ip: ip || req.headers['x-forwarded-for'] || 'unknown',
userId: userId || null,
userAgent: req.headers['user-agent']?.slice(0, 100) || ''
}
if (statusCode >= 400) {
writeLog('ERROR', `${method} ${url} ${statusCode} ${duration}ms`, meta)
} else if (duration > 1000) {
writeLog('WARN', `Slow request: ${method} ${url} ${duration}ms`, meta)
} else {
writeLog('INFO', `${method} ${url} ${statusCode} ${duration}ms`, meta)
}
})
next()
}
/** 错误日志 */
export function logError(message: string, error: Error | any, meta?: Record<string, any>) {
writeLog('ERROR', message, {
...meta,
error: error?.message || String(error),
stack: error?.stack?.slice(0, 500) || ''
})
}
/** 业务日志 */
export function logInfo(message: string, meta?: Record<string, any>) {
writeLog('INFO', message, meta)
}
export default { requestLogger, logError, logInfo }

View File

@@ -0,0 +1,18 @@
import { Response, NextFunction } from 'express'
import pool from '../db/connection'
import { AuthRequest } from './auth'
/** 管理员权限检查中间件 */
export async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
try {
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const user = (rows as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '需要管理员权限' })
}
next()
} catch (err) {
console.error('[Auth] requireAdmin error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
}

View File

@@ -1,19 +1,10 @@
import { Router, Response, NextFunction } from 'express'
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
/** 管理员权限检查中间件 */
async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const user = (rows as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '需要管理员权限,请先登录获取管理员身份' })
}
next()
}
// 所有路由都需要管理员权限
router.use((req, res, next) => requireAdmin(req, res, next))
@@ -22,11 +13,15 @@ router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
try {
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users')
const [txThisMonth] = await pool.query(
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
`SELECT COUNT(*) as count,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as totalIncome
FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
)
const [txLastMonth] = await pool.query(
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
`SELECT COUNT(*) as count,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as totalIncome
FROM transactions
WHERE date >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
AND date < DATE_FORMAT(NOW(), '%Y-%m-01')`
@@ -41,9 +36,9 @@ router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
const thisMonth = (txThisMonth as any[])[0]
const lastMonth = (txLastMonth as any[])[0]
let growth = 0
if (lastMonth.total > 0) {
growth = Math.round(((thisMonth.total - lastMonth.total) / lastMonth.total) * 100)
} else if (thisMonth.total > 0) {
if (lastMonth.totalExpense > 0) {
growth = Math.round(((thisMonth.totalExpense - lastMonth.totalExpense) / lastMonth.totalExpense) * 100)
} else if (thisMonth.totalExpense > 0) {
growth = 100
}
@@ -52,12 +47,14 @@ router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
data: {
totalUsers: (userCount as any[])[0].count,
monthlyTxCount: thisMonth.count,
monthlyTxAmount: thisMonth.total,
monthlyExpense: thisMonth.totalExpense,
monthlyIncome: thisMonth.totalIncome,
dailyActive: (activeToday as any[])[0].count,
monthlyActive: (activeThisMonth as any[])[0].count,
growthPercent: growth,
lastMonthTxCount: lastMonth.count,
lastMonthTxAmount: lastMonth.total
lastMonthExpense: lastMonth.totalExpense,
lastMonthIncome: lastMonth.totalIncome
}
})
} catch (err) {
@@ -78,8 +75,10 @@ router.get('/users', async (req: AuthRequest, res: Response) => {
const params: any[] = []
if (keyword && typeof keyword === 'string' && keyword.trim()) {
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
where = 'WHERE u.nickname LIKE ?'
params.push(`%${keyword.trim()}%`)
params.push(`%${escapedKeyword}%`)
}
const [rows] = await pool.query(
@@ -125,6 +124,17 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40001, message: '不能修改自己的角色' })
}
// 如果要降级管理员,检查是否至少保留一个管理员
if (role === 'user') {
const [adminCount] = await pool.query(
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
[req.params.id]
)
if ((adminCount as any[])[0].count === 0) {
return res.status(400).json({ code: 40002, message: '至少需要保留一个管理员' })
}
}
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id])
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
@@ -144,6 +154,19 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40001, message: '不能删除自己' })
}
// 检查是否是管理员,如果是则确保至少保留一个
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.params.id])
const user = (userRows as any[])[0]
if (user && user.role === 'admin') {
const [adminCount] = await pool.query(
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
[req.params.id]
)
if ((adminCount as any[])[0].count === 0) {
return res.status(400).json({ code: 40002, message: '至少需要保留一个管理员' })
}
}
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [req.params.id])
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' })

View File

@@ -1,29 +1,18 @@
import { Router, Response } from 'express'
import { AuthRequest } from '../middleware/auth'
import { backupDatabase, getBackupList } from '../utils/backup'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
// 管理员用户 ID 列表(逗号分隔),未配置时默认仅 ID=1
const ADMIN_IDS = process.env.ADMIN_USER_IDS
? process.env.ADMIN_USER_IDS.split(',').map(Number)
: [1]
/** 检查是否为管理员,非管理员返回 403 */
function requireAdmin(req: AuthRequest, res: Response): boolean {
if (!req.userId || !ADMIN_IDS.includes(req.userId)) {
res.status(403).json({ code: 40300, message: '无权限' })
return false
}
return true
}
// 所有路由都需要管理员权限
router.use((req, res, next) => requireAdmin(req, res, next))
// 手动触发备份
router.post('/', async (req: AuthRequest, res: Response) => {
if (!requireAdmin(req, res)) return
try {
const filepath = await backupDatabase()
res.json({ code: 0, data: { filepath } })
await backupDatabase()
res.json({ code: 0, message: '备份成功' })
} catch (err) {
console.error('[Backup] 手动备份失败:', err)
res.status(500).json({ code: 50000, message: '备份失败' })
@@ -32,7 +21,6 @@ router.post('/', async (req: AuthRequest, res: Response) => {
// 获取备份列表
router.get('/', async (req: AuthRequest, res: Response) => {
if (!requireAdmin(req, res)) return
try {
const list = getBackupList()
res.json({ code: 0, data: list })

View File

@@ -25,19 +25,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
const [groupRows] = await pool.query(
`SELECT COALESCE(SUM(amount), 0) as amount, month
`SELECT SUM(amount) as amount, month
FROM budgets
WHERE user_id IN (SELECT user_id FROM group_members WHERE group_id = ?) AND month = ?
GROUP BY month`,
[group_id, month]
)
const [myRows] = await pool.query(
'SELECT COALESCE(amount, 0) as amount FROM budgets WHERE user_id = ? AND month = ?',
'SELECT amount FROM budgets WHERE user_id = ? AND month = ?',
[req.userId, month]
)
const groupBudget = (groupRows as any[])[0] || { amount: 0, month }
const groupBudget = (groupRows as any[])[0]
const myAmount = (myRows as any[])[0]?.amount || 0
// 如果没有任何成员设置预算,返回 null
if (!groupBudget) {
res.json({ code: 0, data: null })
} else {
res.json({ code: 0, data: { amount: groupBudget.amount, myAmount, month } })
}
} else {
// 个人视图
const [rows] = await pool.query(

View File

@@ -18,6 +18,34 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
})
// 更新分类排序(批量更新,使用 CASE WHEN 一次完成)
// 注意:此路由必须在 PUT /:id 之前注册,否则 /sort 会被当作 :id 参数
router.put('/sort', async (req: AuthRequest, res: Response) => {
try {
const { ids } = req.body // [id1, id2, id3, ...]
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 校验每个元素必须是正整数
if (!ids.every((id: any) => Number.isInteger(id) && id > 0)) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 使用参数化查询构建 CASE WHEN 语句
const whenClauses = ids.map(() => `WHEN ? THEN ?`).join(' ')
const whenParams: number[] = ids.flatMap((id: number, index: number) => [id, index])
const placeholders = ids.map(() => '?').join(',')
await pool.query(
`UPDATE categories SET sort_order = CASE id ${whenClauses} END WHERE id IN (${placeholders}) AND (user_id = 0 OR user_id = ?)`,
[...whenParams, ...ids, req.userId]
)
res.json({ code: 0 })
} catch (err) {
console.error('[Category] sort error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { name, icon, color, type } = req.body
@@ -96,15 +124,31 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
await conn.beginTransaction()
// 验证目标分类存在且属于当前用户或是默认分类
// 验证分类存在且属于当前用户(仅允许迁移自定义分类
const [sourceRows] = await conn.query(
'SELECT id, type FROM categories WHERE id = ? AND is_custom = 1 AND user_id = ?',
[req.params.id, req.userId]
)
const sourceCategory = (sourceRows as any[])[0]
if (!sourceCategory) {
await conn.rollback()
return res.status(404).json({ code: 40400, message: '源分类不存在' })
}
// 验证目标分类存在且属于当前用户或是默认分类,并且类型一致
const [targetRows] = await conn.query(
'SELECT id FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
'SELECT id, type FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
[targetId, req.userId]
)
if ((targetRows as any[]).length === 0) {
const targetCategory = (targetRows as any[])[0]
if (!targetCategory) {
await conn.rollback()
return res.status(404).json({ code: 40400, message: '目标分类不存在' })
}
if (sourceCategory.type !== targetCategory.type) {
await conn.rollback()
return res.status(400).json({ code: 40002, message: '只能迁移到同类型的分类' })
}
// 迁移记录
await conn.query(
@@ -129,33 +173,6 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
}
})
// 更新分类排序(批量更新,使用 CASE WHEN 一次完成)
router.put('/sort', async (req: AuthRequest, res: Response) => {
try {
const { ids } = req.body // [id1, id2, id3, ...]
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 校验每个元素必须是正整数
if (!ids.every((id: any) => Number.isInteger(id) && id > 0)) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 使用参数化查询构建 CASE WHEN 语句
const whenClauses = ids.map(() => `WHEN ? THEN ?`).join(' ')
const whenParams: number[] = ids.flatMap((id: number, index: number) => [id, index])
const placeholders = ids.map(() => '?').join(',')
await pool.query(
`UPDATE categories SET sort_order = CASE id ${whenClauses} END WHERE id IN (${placeholders}) AND (user_id = 0 OR user_id = ?)`,
[...whenParams, ...ids, req.userId]
)
res.json({ code: 0 })
} catch (err) {
console.error('[Category] sort error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query(

View File

@@ -0,0 +1,54 @@
import { Router } from 'express'
import pool from '../db/connection'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
/** 获取公开配置(经过全局 authMiddleware无需管理员权限 */
router.get('/', async (_req, res) => {
try {
const [rows] = await pool.execute('SELECT config_key, config_value FROM sys_config')
const config: Record<string, string> = {}
for (const row of rows as any[]) {
config[row.config_key] = row.config_value
}
res.json({ code: 0, data: config })
} catch (error) {
console.error('Get config error:', error)
res.status(500).json({ code: 50000, message: '获取配置失败' })
}
})
/** 允许修改的配置键白名单 */
const ALLOWED_KEYS = ['app_name', 'app_version', 'app_slogan', 'app_description']
/** 更新配置(管理员权限) */
router.put('/', requireAdmin, async (req, res) => {
try {
const data = req.body
if (!data || typeof data !== 'object') {
return res.status(400).json({ code: 40001, message: '无效的配置数据' })
}
// 批量更新配置(只允许白名单内的键)
for (const [key, value] of Object.entries(data)) {
if (!ALLOWED_KEYS.includes(key)) {
return res.status(400).json({ code: 40001, message: `不允许修改配置项: ${key}` })
}
if (typeof value !== 'string' || value.length > 500) {
return res.status(400).json({ code: 40001, message: `配置值无效或过长: ${key}` })
}
await pool.query(
'INSERT INTO sys_config (config_key, config_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE config_value = ?',
[key, value, value]
)
}
res.json({ code: 0 })
} catch (error) {
console.error('Update config error:', error)
res.status(500).json({ code: 50000, message: '更新配置失败' })
}
})
export default router

View File

@@ -0,0 +1,129 @@
import { Router } from 'express'
import pool from '../db/connection'
import { authMiddleware } from '../middleware/auth'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
/** 用户提交反馈 */
router.post('/', authMiddleware, async (req, res) => {
try {
const userId = (req as any).userId
const { type, content, contact } = req.body
if (!content || !content.trim()) {
return res.status(400).json({ code: 40001, message: '请填写反馈内容' })
}
const validTypes = ['bug', 'feature', 'ux', 'other']
const feedbackType = validTypes.includes(type) ? type : 'other'
await pool.execute(
'INSERT INTO feedbacks (user_id, type, content, contact) VALUES (?, ?, ?, ?)',
[userId, feedbackType, content.trim(), contact || '']
)
res.json({ code: 0 })
} catch (error) {
console.error('Submit feedback error:', error)
res.status(500).json({ code: 50000, message: '提交失败' })
}
})
/** 管理员获取反馈列表 */
router.get('/', requireAdmin, async (req, res) => {
try {
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 status = req.query.status as string
const offset = (page - 1) * pageSize
let where = '1=1'
const params: any[] = []
if (status && ['pending', 'processed', 'ignored'].includes(status)) {
where += ' AND f.status = ?'
params.push(status)
}
const [countResult] = await pool.execute(
`SELECT COUNT(*) as total FROM feedbacks f WHERE ${where}`,
params
)
const total = (countResult as any[])[0].total
// LIMIT/OFFSET 直接嵌入 SQLexecute 对参数类型要求严格)
const [rows] = await pool.execute(
`SELECT f.*, u.nickname, u.avatar_url
FROM feedbacks f
LEFT JOIN users u ON f.user_id = u.id
WHERE ${where}
ORDER BY f.created_at DESC
LIMIT ${pageSize} OFFSET ${offset}`,
params
)
res.json({ code: 0, data: { list: rows, total, page, pageSize } })
} catch (error) {
console.error('Get feedback list error:', error)
res.status(500).json({ code: 50000, message: '获取失败' })
}
})
/** 管理员更新反馈状态 */
router.put('/:id/status', requireAdmin, async (req, res) => {
try {
const { id } = req.params
const { status, admin_reply } = req.body
const validStatuses = ['pending', 'processed', 'ignored']
if (!validStatuses.includes(status)) {
return res.status(400).json({ code: 40001, message: '无效的状态' })
}
// 查询反馈信息(用于发送通知)
const [feedbackRows] = await pool.execute(
'SELECT user_id, content FROM feedbacks WHERE id = ?',
[id]
)
const feedback = (feedbackRows as any[])[0]
if (!feedback) {
return res.status(404).json({ code: 40400, message: '反馈不存在' })
}
await pool.execute(
'UPDATE feedbacks SET status = ?, admin_reply = ? WHERE id = ?',
[status, admin_reply || '', id]
)
// 如果有管理员回复,发送通知给反馈提交者
if (feedback && admin_reply && admin_reply.trim()) {
const statusText: Record<string, string> = {
processed: '已处理',
ignored: '已忽略',
pending: '待处理'
}
const replyPreview = admin_reply.trim().length > 50
? admin_reply.trim().slice(0, 50) + '...'
: admin_reply.trim()
await pool.execute(
`INSERT INTO notifications (user_id, type, title, content)
VALUES (?, 'personal', ?, ?)`,
[
feedback.user_id,
`您的反馈已${statusText[status] || '更新'}`,
`反馈内容:${feedback.content.slice(0, 30)}...\n\n管理员回复${replyPreview}`
]
)
}
res.json({ code: 0 })
} catch (error) {
console.error('Update feedback status error:', error)
res.status(500).json({ code: 50000, message: '更新失败' })
}
})
export default router

View File

@@ -36,6 +36,9 @@ router.post('/', async (req: AuthRequest, res: Response) => {
inviteCode = generateInviteCode()
attempts++
}
if (attempts >= 10) {
return res.status(500).json({ code: 50001, message: '生成邀请码失败,请重试' })
}
const conn = await pool.getConnection()
try {
@@ -103,6 +106,15 @@ router.post('/join', async (req: AuthRequest, res: Response) => {
return res.status(404).json({ code: 40400, message: '邀请码无效' })
}
// 检查群组是否已解散(没有任何成员)
const [memberCount] = await pool.query(
'SELECT COUNT(*) as count FROM group_members WHERE group_id = ?',
[group.id]
)
if ((memberCount as any[])[0].count === 0) {
return res.status(400).json({ code: 40003, message: '该群组已解散' })
}
// 使用 INSERT IGNORE 避免并发加入时的竞态条件
const [result] = await pool.query(
'INSERT IGNORE INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
@@ -139,37 +151,20 @@ router.post('/:id/leave', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40003, message: '群主不能退出,请先转让或解散群组' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 清除该用户在该群组的记录标签
await conn.query(
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
[req.userId, groupId]
)
// 删除成员关系
await conn.query(
// 删除成员关系(保留 transactions.group_id历史记录仍属于该群组
await pool.query(
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, req.userId]
)
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Group] leave error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 解散群组(仅 owner */
/** 解散群组(仅 owner — 保留群组记录和 transactions.group_id仅移除所有成员 */
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [groupRows] = await pool.query(
@@ -184,24 +179,10 @@ router.delete('/:id', async (req: AuthRequest, res: Response) => {
return res.status(403).json({ code: 40300, message: '仅群主可解散群组' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 移除所有成员(保留群组记录和 transactions.group_id历史数据不丢失
await pool.query('DELETE FROM group_members WHERE group_id = ?', [req.params.id])
// 清除该群组所有记录的标签
await conn.query('UPDATE transactions SET group_id = NULL WHERE group_id = ?', [req.params.id])
// 删除群组CASCADE 自动清理 group_members
await conn.query('DELETE FROM `groups` WHERE id = ?', [req.params.id])
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Group] DELETE error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
@@ -235,6 +216,9 @@ router.post('/:id/refresh-code', async (req: AuthRequest, res: Response) => {
inviteCode = generateInviteCode()
attempts++
}
if (attempts >= 10) {
return res.status(500).json({ code: 50001, message: '生成邀请码失败,请重试' })
}
await pool.query('UPDATE `groups` SET invite_code = ? WHERE id = ?', [inviteCode, groupId])
res.json({ code: 0, data: { invite_code: inviteCode } })
@@ -299,30 +283,13 @@ router.delete('/:id/members/:userId', async (req: AuthRequest, res: Response) =>
return res.status(403).json({ code: 40300, message: '仅群主可移除成员' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 清除该成员在群组的记录标签
await conn.query(
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
[targetUserId, groupId]
)
// 删除成员关系
await conn.query(
// 删除成员关系(保留 transactions.group_id历史记录仍属于该群组
await pool.query(
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, targetUserId]
)
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Group] remove member error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })

233
server/src/routes/logs.ts Normal file
View File

@@ -0,0 +1,233 @@
import { Router } from 'express'
import fs from 'fs'
import path from 'path'
import pool from '../db/connection'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
const LOG_DIR = path.resolve(process.env.LOG_DIR || './logs')
/** 获取今日日期字符串 YYYY-MM-DD服务器本地时间 */
function getTodayStr(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** 获取日志列表最近7天 */
router.get('/files', requireAdmin, (_req, res) => {
try {
if (!fs.existsSync(LOG_DIR)) {
return res.json({ code: 0, data: [] })
}
const files = fs.readdirSync(LOG_DIR)
.filter(f => f.endsWith('.log'))
.sort()
.reverse()
.slice(0, 7) // 最多返回7天
const fileList = files.map(f => {
const filePath = path.join(LOG_DIR, f)
const stat = fs.statSync(filePath)
return {
name: f,
date: f.replace('.log', ''),
size: stat.size,
modified: stat.mtime.toISOString()
}
})
res.json({ code: 0, data: fileList })
} catch (err) {
console.error('[Logs] List error:', err)
res.status(500).json({ code: 50000, message: '获取日志列表失败' })
}
})
/** 获取日志统计 */
router.get('/stats', requireAdmin, (_req, res) => {
try {
if (!fs.existsSync(LOG_DIR)) {
return res.json({ code: 0, data: { today: { total: 0, errors: 0, slow: 0 }, files: [] } })
}
const dateStr = getTodayStr()
const todayFile = path.join(LOG_DIR, `${dateStr}.log`)
let todayStats = { total: 0, errors: 0, slow: 0, warns: 0 }
if (fs.existsSync(todayFile)) {
const content = fs.readFileSync(todayFile, 'utf-8')
const lines = content.split('\n').filter(l => l.trim())
todayStats.total = lines.length
todayStats.errors = lines.filter(l => l.includes(' ERROR ')).length
todayStats.slow = lines.filter(l => l.includes('Slow request')).length
todayStats.warns = lines.filter(l => l.includes(' WARN ')).length
}
// 最近7天统计
const files = fs.readdirSync(LOG_DIR)
.filter(f => f.endsWith('.log'))
.sort()
.reverse()
.slice(0, 7)
const dailyStats = files.map(f => {
const filePath = path.join(LOG_DIR, f)
const content = fs.readFileSync(filePath, 'utf-8')
const lines = content.split('\n').filter(l => l.trim())
return {
date: f.replace('.log', ''),
total: lines.length,
errors: lines.filter(l => l.includes(' ERROR ')).length,
slow: lines.filter(l => l.includes('Slow request')).length
}
})
res.json({
code: 0,
data: {
today: todayStats,
daily: dailyStats
}
})
} catch (err) {
console.error('[Logs] Stats error:', err)
res.status(500).json({ code: 50000, message: '获取日志统计失败' })
}
})
/** 获取埋点统计(必须在 /:date 之前注册) */
router.get('/track/stats', requireAdmin, async (_req, res) => {
try {
// 今日统计
const [todayRows] = await pool.execute(
`SELECT event, COUNT(*) as count FROM track_events
WHERE DATE(created_at) = CURDATE()
GROUP BY event ORDER BY count DESC`
)
// 最近7天每日统计
const [dailyRows] = await pool.execute(
`SELECT DATE(created_at) as date, COUNT(*) as count FROM track_events
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY DATE(created_at) ORDER BY date DESC`
)
// 最近7天事件分布
const [eventRows] = await pool.execute(
`SELECT event, COUNT(*) as count FROM track_events
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY event ORDER BY count DESC LIMIT 10`
)
res.json({
code: 0,
data: {
today: todayRows,
daily: dailyRows,
events: eventRows
}
})
} catch (err) {
console.error('[Track] Stats error:', err)
res.status(500).json({ code: 50000, message: '获取埋点统计失败' })
}
})
/** 查询埋点事件(必须在 /:date 之前注册) */
router.get('/track/list', requireAdmin, async (req, res) => {
try {
const { event, page = '1', pageSize = '50' } = req.query
const pNum = Math.max(1, parseInt(page as string) || 1)
const pSize = Math.min(200, Math.max(1, parseInt(pageSize as string) || 50))
const offset = (pNum - 1) * pSize
let where = '1=1'
const params: any[] = []
if (event && typeof event === 'string') {
where += ' AND t.event = ?'
params.push(event)
}
const [countResult] = await pool.execute(
`SELECT COUNT(*) as total FROM track_events t WHERE ${where}`,
params
)
const total = (countResult as any[])[0].total
// LIMIT/OFFSET 直接嵌入 SQLmysql2 execute 对这些参数类型要求严格)
const [rows] = await pool.execute(
`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 ${pSize} OFFSET ${offset}`,
params
)
res.json({
code: 0,
data: { list: rows, total, page: pNum, pageSize: pSize }
})
} catch (err) {
console.error('[Track] List error:', err)
res.status(500).json({ code: 50000, message: '查询埋点失败' })
}
})
/** 查询指定日期的日志(必须在 /track/* 之后注册) */
router.get('/:date', requireAdmin, (req, res) => {
try {
const { date } = req.params
const { level, keyword, page = '1', pageSize = '100' } = req.query
// 日期格式校验
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({ code: 40001, message: '日期格式无效' })
}
const logFile = path.join(LOG_DIR, `${date}.log`)
if (!fs.existsSync(logFile)) {
return res.json({ code: 0, data: { lines: [], total: 0 } })
}
const content = fs.readFileSync(logFile, 'utf-8')
let lines = content.split('\n').filter(l => l.trim())
// 按级别过滤
const levelStr = typeof level === 'string' ? level : ''
if (levelStr && levelStr !== 'all') {
lines = lines.filter(l => l.includes(` ${levelStr.toUpperCase()} `))
}
// 按关键词过滤
if (keyword && typeof keyword === 'string') {
const kw = keyword.toLowerCase()
lines = lines.filter(l => l.toLowerCase().includes(kw))
}
const total = lines.length
const pNum = Math.max(1, parseInt(page as string) || 1)
const pSize = Math.min(500, Math.max(1, parseInt(pageSize as string) || 100))
const offset = (pNum - 1) * pSize
const paged = lines.slice(offset, offset + pSize)
res.json({
code: 0,
data: {
lines: paged,
total,
page: pNum,
pageSize: pSize
}
})
} catch (err) {
console.error('[Logs] Query error:', err)
res.status(500).json({ code: 50000, message: '查询日志失败' })
}
})
export default router

View File

@@ -1,17 +1,85 @@
import { Router, Response, NextFunction } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
/** 管理员权限检查 */
async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const user = (rows as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '需要管理员权限' })
// 通知图片上传配置
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
const NOTIFICATION_DIR = path.resolve(UPLOAD_DIR, 'notifications')
if (!fs.existsSync(NOTIFICATION_DIR)) {
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
if (!fs.existsSync(NOTIFICATION_DIR)) {
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
}
cb(null, NOTIFICATION_DIR)
},
filename: (req: AuthRequest, file, cb) => {
const extMap: Record<string, string> = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'
}
const ext = extMap[file.mimetype] || '.jpg'
cb(null, `notif_${req.userId}_${Date.now()}${ext}`)
}
})
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 最大 5MB
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (allowed.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
}
}
})
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
try {
const [rows] = await pool.query(
'SELECT type, group_id FROM notifications WHERE id = ?',
[req.params.id]
)
const notification = (rows as any[])[0]
if (!notification) {
return res.status(404).json({ code: 40400, message: '公告不存在' })
}
// 检查是否是管理员
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const user = (userRows as any[])[0]
if (user && user.role === 'admin') {
return next()
}
// 群组公告:检查是否是群主
if (notification.type === 'group' && notification.group_id) {
const [groupRows] = await pool.query(
'SELECT created_by FROM `groups` WHERE id = ?',
[notification.group_id]
)
const group = (groupRows as any[])[0]
if (group && group.created_by === req.userId) {
return next()
}
}
return res.status(403).json({ code: 40300, message: '需要管理员或群主权限' })
} catch (err) {
console.error('[Notification] editAuth error:', err)
return res.status(500).json({ code: 50000, message: '服务器错误' })
}
next()
}
/** 获取通知列表 */
@@ -34,13 +102,18 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
const [rows] = await pool.query(
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at, n.group_id,
n.is_pinned, n.is_urgent, n.publish_at, n.expire_at, n.image_url, n.link_url
`SELECT n.id, n.type, n.title, n.content, n.created_at, n.group_id,
n.is_pinned, n.is_urgent, n.publish_at, n.expire_at, n.image_url, n.link_url,
CASE
WHEN n.type = 'personal' THEN n.is_read
ELSE CASE WHEN nr.notification_id IS NOT NULL THEN 1 ELSE 0 END
END as is_read
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
${where}
ORDER BY n.is_pinned DESC, n.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pSize, offset]
[req.userId, ...params, pSize, offset]
)
const [countResult] = await pool.query(
@@ -67,16 +140,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
router.get('/pinned', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at,
n.is_pinned, n.is_urgent, n.image_url, n.link_url
`SELECT * FROM (
SELECT n.id, n.type, n.title, n.content, n.created_at,
n.is_pinned, n.is_urgent, n.image_url, n.link_url,
CASE
WHEN n.type = 'personal' THEN n.is_read
ELSE CASE WHEN nr.notification_id IS NOT NULL THEN 1 ELSE 0 END
END as is_read
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
WHERE (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
AND n.is_urgent = 1 AND n.is_read = 0
AND n.is_urgent = 1
AND (n.expire_at IS NULL OR n.expire_at > NOW())
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
ORDER BY n.created_at DESC
) AS sub
WHERE is_read = 0
ORDER BY created_at DESC
LIMIT 5`,
[req.userId, req.userId]
[req.userId, req.userId, req.userId]
)
res.json({ code: 0, data: rows })
} catch (err) {
@@ -89,12 +170,19 @@ router.get('/pinned', async (req: AuthRequest, res: Response) => {
router.get('/unread-count', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
`SELECT COUNT(*) as count FROM notifications
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
AND is_read = 0
AND (expire_at IS NULL OR expire_at > NOW())
AND (publish_at IS NULL OR publish_at <= NOW())`,
[req.userId, req.userId]
`SELECT COUNT(*) as count FROM (
SELECT n.id
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
WHERE (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
AND (n.expire_at IS NULL OR n.expire_at > NOW())
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
AND (
(n.type = 'personal' AND n.is_read = 0)
OR (n.type != 'personal' AND nr.notification_id IS NULL)
)
) as unread`,
[req.userId, req.userId, req.userId]
)
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
} catch (err) {
@@ -106,14 +194,29 @@ router.get('/unread-count', async (req: AuthRequest, res: Response) => {
/** 标记单条已读 */
router.put('/:id/read', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query(
`UPDATE notifications SET is_read = 1 WHERE id = ?
AND (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))`,
// 先检查通知是否存在且用户可见
const [notiRows] = await pool.query(
`SELECT n.id, n.type, n.user_id FROM notifications n
WHERE n.id = ?
AND (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))`,
[req.params.id, req.userId, req.userId]
)
if ((result as any).affectedRows === 0) {
const notification = (notiRows as any[])[0]
if (!notification) {
return res.status(404).json({ code: 40400, message: '通知不存在' })
}
if (notification.type === 'personal' && notification.user_id === req.userId) {
// 个人通知:直接更新 is_read 字段
await pool.query('UPDATE notifications SET is_read = 1 WHERE id = ?', [req.params.id])
} else {
// 系统/群组公告:插入 notification_reads 记录IGNORE 避免重复)
await pool.query(
'INSERT IGNORE INTO notification_reads (notification_id, user_id) VALUES (?, ?)',
[req.params.id, req.userId]
)
}
res.json({ code: 0 })
} catch (err) {
console.error('[Notification] mark-read error:', err)
@@ -123,21 +226,72 @@ router.put('/:id/read', async (req: AuthRequest, res: Response) => {
/** 全部标记已读 */
router.put('/read-all', async (req: AuthRequest, res: Response) => {
const conn = await pool.getConnection()
try {
await pool.query(
await conn.beginTransaction()
// 1. 标记所有个人通知为已读
await conn.query(
`UPDATE notifications SET is_read = 1
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
AND is_read = 0
WHERE user_id = ? AND type = 'personal' AND is_read = 0
AND (expire_at IS NULL OR expire_at > NOW())
AND (publish_at IS NULL OR publish_at <= NOW())`,
[req.userId, req.userId]
[req.userId]
)
// 2. 为所有未读的系统/群组公告插入已读记录
await conn.query(
`INSERT IGNORE INTO notification_reads (notification_id, user_id)
SELECT n.id, ?
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
WHERE ((n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
AND (n.expire_at IS NULL OR n.expire_at > NOW())
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
AND nr.notification_id IS NULL`,
[req.userId, req.userId, req.userId]
)
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
console.error('[Notification] read-all error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
} finally {
conn.release()
}
})
/** 上传通知图片(管理员) */
router.post('/upload-image', requireAdmin, (req: AuthRequest, res: Response) => {
upload.single('file')(req, res, async (err) => {
if (err) {
return res.status(400).json({ code: 40001, message: err.message })
}
if (!req.file) {
return res.status(400).json({ code: 40001, message: '请选择图片文件' })
}
try {
res.json({ code: 0, data: { image_url: req.file.filename } })
} catch (err) {
console.error('[Notification] image upload error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
})
/** 验证 URL 安全性(防止 javascript: 等危险协议) */
function isValidUrl(url: string): boolean {
if (!url) return true // 空值允许
try {
const parsed = new URL(url)
return ['http:', 'https:'].includes(parsed.protocol)
} catch {
// 非标准 URL 格式,小程序不支持相对路径,拒绝
return false
}
}
/** 发布公告(管理员/群主) */
router.post('/', async (req: AuthRequest, res: Response) => {
@@ -150,6 +304,12 @@ router.post('/', async (req: AuthRequest, res: Response) => {
if (title.length > 100) {
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
}
if (content && typeof content === 'string' && content.length > 5000) {
return res.status(400).json({ code: 40001, message: '内容不能超过5000字' })
}
if (link_url && !isValidUrl(link_url)) {
return res.status(400).json({ code: 40001, message: '链接格式无效' })
}
// 系统公告需要管理员权限
if (type === 'system') {
@@ -160,8 +320,11 @@ router.post('/', async (req: AuthRequest, res: Response) => {
}
}
// 群组公告需要群主权限
if (type === 'group' && group_id) {
// 群组公告需要 group_id 且需要群主权限
if (type === 'group') {
if (!group_id || typeof group_id !== 'number') {
return res.status(400).json({ code: 40001, message: '群组公告必须指定群组' })
}
const [groupRows] = await pool.query('SELECT created_by FROM `groups` WHERE id = ?', [group_id])
const group = (groupRows as any[])[0]
if (!group || group.created_by !== req.userId) {
@@ -194,11 +357,25 @@ router.post('/', async (req: AuthRequest, res: Response) => {
}
})
/** 编辑公告(管理员) */
router.put('/:id', requireAdmin, async (req: AuthRequest, res: Response) => {
/** 编辑公告(管理员或群主 */
router.put('/:id', requireNotificationEditAuth, async (req: AuthRequest, res: Response) => {
try {
const { title, content, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url } = req.body
// 验证字段
if (title !== undefined && (typeof title !== 'string' || title.trim().length === 0)) {
return res.status(400).json({ code: 40001, message: '标题不能为空' })
}
if (title && title.length > 100) {
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
}
if (content && typeof content === 'string' && content.length > 5000) {
return res.status(400).json({ code: 40001, message: '内容不能超过5000字' })
}
if (link_url && !isValidUrl(link_url)) {
return res.status(400).json({ code: 40001, message: '链接格式无效' })
}
const updates: string[] = []
const params: any[] = []

View File

@@ -0,0 +1,194 @@
import { Router } from 'express'
import pool from '../db/connection'
import { authMiddleware } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
/** 获取用户的周期模板列表 */
router.get('/', async (req, res) => {
try {
const userId = (req as any).userId
const [rows] = await pool.execute(
`SELECT rt.*, c.name as category_name, c.icon as category_icon, c.color as category_color
FROM recurring_templates rt
LEFT JOIN categories c ON rt.category_id = c.id
WHERE rt.user_id = ?
ORDER BY rt.is_active DESC, rt.next_date ASC`,
[userId]
)
res.json({ code: 0, data: rows })
} catch (error) {
console.error('Get recurring templates error:', error)
res.status(500).json({ code: 50000, message: '获取失败' })
}
})
/** 创建周期模板 */
router.post('/', async (req, res) => {
try {
const userId = (req as any).userId
const { amount, type, category_id, note, frequency, next_date, end_date, group_id } = req.body
if (!amount || amount <= 0) {
return res.status(400).json({ code: 40001, message: '请输入金额' })
}
if (!type || !['expense', 'income'].includes(type)) {
return res.status(400).json({ code: 40001, message: '请选择类型' })
}
if (!category_id) {
return res.status(400).json({ code: 40001, message: '请选择分类' })
}
if (!frequency || !['weekly', 'monthly', 'yearly'].includes(frequency)) {
return res.status(400).json({ code: 40001, message: '请选择周期' })
}
if (!next_date) {
return res.status(400).json({ code: 40001, message: '请选择开始日期' })
}
const [result] = await pool.execute(
`INSERT INTO recurring_templates (user_id, amount, type, category_id, note, frequency, next_date, end_date, group_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[userId, amount, type, category_id, note || '', frequency, next_date, end_date || null, group_id || null]
)
res.json({ code: 0, data: { id: (result as any).insertId } })
} catch (error) {
console.error('Create recurring template error:', error)
res.status(500).json({ code: 50000, message: '创建失败' })
}
})
/** 更新周期模板 */
router.put('/:id', async (req, res) => {
try {
const userId = (req as any).userId
const { id } = req.params
const { amount, type, category_id, note, frequency, next_date, end_date, is_active } = req.body
// 校验所有权
const [rows] = await pool.execute(
'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?',
[id, userId]
)
if ((rows as any[]).length === 0) {
return res.status(404).json({ code: 40400, message: '模板不存在' })
}
const updates: string[] = []
const params: any[] = []
if (amount !== undefined) { updates.push('amount = ?'); params.push(amount) }
if (type !== undefined) { updates.push('type = ?'); params.push(type) }
if (category_id !== undefined) { updates.push('category_id = ?'); params.push(category_id) }
if (note !== undefined) { updates.push('note = ?'); params.push(note) }
if (frequency !== undefined) { updates.push('frequency = ?'); params.push(frequency) }
if (next_date !== undefined) { updates.push('next_date = ?'); params.push(next_date) }
if (end_date !== undefined) { updates.push('end_date = ?'); params.push(end_date) }
if (is_active !== undefined) { updates.push('is_active = ?'); params.push(is_active) }
if (updates.length === 0) {
return res.status(400).json({ code: 40001, message: '无更新字段' })
}
params.push(id, userId)
await pool.execute(
`UPDATE recurring_templates SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
params
)
res.json({ code: 0 })
} catch (error) {
console.error('Update recurring template error:', error)
res.status(500).json({ code: 50000, message: '更新失败' })
}
})
/** 删除周期模板 */
router.delete('/:id', async (req, res) => {
try {
const userId = (req as any).userId
const { id } = req.params
const [rows] = await pool.execute(
'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?',
[id, userId]
)
if ((rows as any[]).length === 0) {
return res.status(404).json({ code: 40400, message: '模板不存在' })
}
await pool.execute(
'DELETE FROM recurring_templates WHERE id = ? AND user_id = ?',
[id, userId]
)
res.json({ code: 0 })
} catch (error) {
console.error('Delete recurring template error:', error)
res.status(500).json({ code: 50000, message: '删除失败' })
}
})
/** 同步:检查所有活跃模板,生成到期交易 */
router.post('/sync', async (req, res) => {
try {
const userId = (req as any).userId
// 获取该用户所有活跃且到期next_date <= 今天)的模板
const [templates] = await pool.execute(
`SELECT * FROM recurring_templates
WHERE user_id = ? AND is_active = 1 AND next_date <= CURDATE()
ORDER BY next_date ASC`,
[userId]
)
let generated = 0
const today = new Date()
const todayStr = today.toISOString().slice(0, 10)
for (const t of templates as any[]) {
let next = new Date(t.next_date)
const end = t.end_date ? new Date(t.end_date) : null
// 生成所有错过的交易
while (next <= today) {
if (end && next > end) break
// 检查是否已生成过(避免重复)
const [existing] = await pool.execute(
`SELECT id FROM transactions
WHERE user_id = ? AND recurring_id = ? AND date = ?`,
[userId, t.id, next.toISOString().slice(0, 10)]
)
if ((existing as any[]).length === 0) {
await pool.execute(
`INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id, recurring_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[userId, t.amount, t.type, t.category_id, t.note,
next.toISOString().slice(0, 10), t.group_id, t.id]
)
generated++
}
// 推进到下一周期
if (t.frequency === 'weekly') {
next = new Date(next.getTime() + 7 * 86400000)
} else if (t.frequency === 'monthly') {
next = new Date(next.getFullYear(), next.getMonth() + 1, next.getDate())
} else if (t.frequency === 'yearly') {
next = new Date(next.getFullYear() + 1, next.getMonth(), next.getDate())
}
}
// 更新 next_date循环结束后 next 已推进到今天之后的下一周期,直接使用
await pool.execute(
'UPDATE recurring_templates SET next_date = ? WHERE id = ?',
[next.toISOString().slice(0, 10), t.id]
)
}
res.json({ code: 0, data: { generated } })
} catch (error) {
console.error('Sync recurring error:', error)
res.status(500).json({ code: 50000, message: '同步失败' })
}
})
export default router

View File

@@ -22,8 +22,9 @@ async function buildWhereClause(
)
if ((memberCheck as any[]).length === 0) return null
params.push(groupId, groupId)
let where = `WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
// 群组视图:统计所有群组成员的个人账单
params.push(groupId)
let where = `WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
return { where, params }
}
@@ -123,7 +124,7 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${result.where}
GROUP BY c.id
GROUP BY c.id, c.name, c.icon, c.color
ORDER BY amount DESC`,
[...result.params, type, startDate, endDate]
)
@@ -169,4 +170,99 @@ router.get('/trend', async (req: AuthRequest, res: Response) => {
}
})
/** 智能分类建议:根据历史记录的金额和备注推荐分类 */
router.get('/suggest-category', async (req: AuthRequest, res: Response) => {
try {
const userId = req.userId
const { amount, type = 'expense', note = '' } = req.query
if (!amount || !['expense', 'income'].includes(type as string)) {
return res.status(400).json({ code: 40001, message: '参数错误' })
}
const amt = parseInt(amount as string)
const noteStr = (note as string).trim()
// 1. 关键词匹配:从备注中提取关键词,查找历史记录中最常用的分类
let suggestedCategory: any = null
let confidence = 0
if (noteStr) {
// 提取关键词取前4个字符以上的词
const keywords = noteStr.replace(/[,。.!?\s]+/g, ' ').split(' ').filter(w => w.length >= 1)
if (keywords.length > 0) {
const likeClauses = keywords.map(() => 't.note LIKE ?').join(' OR ')
const likeParams = keywords.map(k => `%${k}%`)
const [rows] = await pool.query(
`SELECT t.category_id, c.name, c.color, COUNT(*) as cnt
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ? AND t.type = ? AND (${likeClauses})
GROUP BY t.category_id, c.name, c.color
ORDER BY cnt DESC
LIMIT 1`,
[userId, type, ...likeParams]
)
const match = (rows as any[])[0]
if (match) {
suggestedCategory = { id: match.category_id, name: match.name, color: match.color }
// 置信度基于匹配次数
confidence = Math.min(90, 40 + match.cnt * 15)
}
}
}
// 2. 金额相近匹配±30%
if (!suggestedCategory) {
const minAmt = Math.floor(amt * 0.7)
const maxAmt = Math.ceil(amt * 1.3)
const [rows] = await pool.query(
`SELECT t.category_id, c.name, c.color, COUNT(*) as cnt
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ? AND t.type = ? AND t.amount BETWEEN ? AND ?
GROUP BY t.category_id, c.name, c.color
ORDER BY cnt DESC
LIMIT 1`,
[userId, type, minAmt, maxAmt]
)
const match = (rows as any[])[0]
if (match) {
suggestedCategory = { id: match.category_id, name: match.name, color: match.color }
confidence = Math.min(70, 20 + match.cnt * 10)
}
}
// 3. 最近使用的分类(兜底)
if (!suggestedCategory) {
const [rows] = await pool.query(
`SELECT t.category_id, c.name, c.color
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ? AND t.type = ?
ORDER BY t.created_at DESC
LIMIT 1`,
[userId, type]
)
const match = (rows as any[])[0]
if (match) {
suggestedCategory = { id: match.category_id, name: match.name, color: match.color }
confidence = 20
}
}
res.json({
code: 0,
data: {
category: suggestedCategory,
confidence
}
})
} catch (err) {
console.error('[Stats] suggest-category error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -0,0 +1,34 @@
import { Router } from 'express'
import pool from '../db/connection'
const router = Router()
/** 接收前端埋点数据 */
router.post('/', async (req, res) => {
try {
const { events } = req.body
const userId = (req as any).userId || null
if (!Array.isArray(events)) {
return res.status(400).json({ code: 40001, message: '无效的埋点数据' })
}
// 批量插入埋点事件(限制每个事件的字段长度)
for (const event of events.slice(0, 50)) { // 最多 50 个事件
const eventType = typeof event.event === 'string' ? event.event.slice(0, 50) : 'unknown'
const page = typeof event.page === 'string' ? event.page.slice(0, 200) : ''
const data = event.data && typeof event.data === 'object' ? event.data : {}
await pool.execute(
'INSERT INTO track_events (event, page, data, user_id) VALUES (?, ?, ?, ?)',
[eventType, page, JSON.stringify(data), userId]
)
}
res.json({ code: 0 })
} catch {
res.json({ code: 0 }) // 埋点失败不影响业务
}
})
export default router

View File

@@ -15,6 +15,7 @@ function safeInt(val: any, fallback: number, min: number, max: number): number {
router.get('/:id', async (req: AuthRequest, res: Response) => {
try {
// 用户可以查看:自己的记录 + 自己所在群组成员的所有记录
const [rows] = await pool.query(
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
c.name as category_name, c.icon as category_icon, c.color as category_color,
@@ -24,11 +25,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
LEFT JOIN users u ON t.user_id = u.id
WHERE t.id = ? AND (
t.user_id = ?
OR t.user_id IN (
SELECT gm2.user_id FROM group_members gm1
JOIN group_members gm2 ON gm1.group_id = gm2.group_id
WHERE gm1.user_id = ?
)
OR t.user_id IN (SELECT user_id FROM group_members WHERE group_id IN (SELECT group_id FROM group_members WHERE user_id = ?))
)`,
[req.params.id, req.userId, req.userId]
)
@@ -54,7 +51,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
const params: any[] = []
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]
@@ -62,8 +59,9 @@ router.get('/', async (req: AuthRequest, res: Response) => {
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
}
where = 'WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
params.push(group_id, group_id)
// 群组账单 = 所有群组成员的个人账单
where = 'WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
params.push(group_id)
} else {
// 个人视图:我的所有记录(不管 group_id
where = 'WHERE t.user_id = ?'
@@ -102,7 +100,9 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
}
if (keyword && typeof keyword === 'string' && keyword.trim()) {
where += ' AND t.note LIKE ?'; params.push(`%${keyword.trim()}%`)
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
where += ' AND t.note LIKE ?'; params.push(`%${escapedKeyword}%`)
}
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'

View File

@@ -48,7 +48,7 @@ const upload = multer({
router.get('/me', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
'SELECT id, nickname, avatar_url, role, created_at FROM users WHERE id = ?',
'SELECT id, nickname, avatar_url, slogan, role, created_at FROM users WHERE id = ?',
[req.userId]
)
const user = (rows as any[])[0]
@@ -62,10 +62,10 @@ router.get('/me', async (req: AuthRequest, res: Response) => {
}
})
/** 更新用户昵称 */
/** 更新用户昵称/签名 */
router.put('/me', async (req: AuthRequest, res: Response) => {
try {
const { nickname } = req.body
const { nickname, slogan } = req.body
if (nickname !== undefined) {
if (typeof nickname !== 'string' || nickname.trim().length === 0) {
@@ -76,6 +76,15 @@ router.put('/me', async (req: AuthRequest, res: Response) => {
}
}
if (slogan !== undefined) {
if (typeof slogan !== 'string') {
return res.status(400).json({ code: 40001, message: '签名格式错误' })
}
if (slogan.length > 100) {
return res.status(400).json({ code: 40001, message: '签名不能超过100个字符' })
}
}
const updates: string[] = []
const params: any[] = []
@@ -84,6 +93,11 @@ router.put('/me', async (req: AuthRequest, res: Response) => {
params.push(nickname.trim())
}
if (slogan !== undefined) {
updates.push('slogan = ?')
params.push(slogan.trim())
}
if (updates.length === 0) {
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
}
@@ -118,7 +132,11 @@ router.post('/avatar', (req: AuthRequest, res: Response) => {
if (oldUrl) {
const oldPath = path.join(AVATAR_DIR, oldUrl)
if (fs.existsSync(oldPath)) {
try {
fs.unlinkSync(oldPath)
} catch (e) {
console.warn('[User] 删除旧头像失败:', e)
}
}
}

View File

@@ -8,9 +8,13 @@ const gzipAsync = promisify(zlib.gzip)
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
// 分页查询大小,避免一次性加载大表到内存
const QUERY_BATCH_SIZE = 1000
/**
* 使用 Node.js mysql2 备份数据库(不依赖 mysqldump
* 导出所有表的 INSERT 语句gzip 压缩存储
* 使用分页查询避免大表内存问题
*/
export async function backupDatabase(): Promise<string> {
if (!fs.existsSync(BACKUP_DIR)) {
@@ -35,31 +39,40 @@ export async function backupDatabase(): Promise<string> {
lines.push(`-- 表: ${tableName}`)
lines.push(`-- ----------------------------`)
// 获取表数
const [rows] = await pool.query(`SELECT * FROM \`${tableName}\``)
const data = rows as any[]
// 获取表总行
const [countResult] = await pool.query(`SELECT COUNT(*) as count FROM \`${tableName}\``)
const totalRows = (countResult as any[])[0].count
if (data.length === 0) {
if (totalRows === 0) {
lines.push(`-- ${tableName}: 无数据`)
lines.push('')
continue
}
// 获取列名
const columns = Object.keys(data[0])
// 获取列名(从第一行)
const [firstRow] = await pool.query(`SELECT * FROM \`${tableName}\` LIMIT 1`)
const columns = Object.keys((firstRow as any[])[0])
const colList = columns.map(c => `\`${c}\``).join(', ')
// 生成 INSERT 语句(每 100 行一批)
const BATCH_SIZE = 100
for (let i = 0; i < data.length; i += BATCH_SIZE) {
const batch = data.slice(i, i + BATCH_SIZE)
// 分页查询并生成 INSERT 语句
let offset = 0
while (offset < totalRows) {
const [rows] = await pool.query(
`SELECT * FROM \`${tableName}\` LIMIT ? OFFSET ?`,
[QUERY_BATCH_SIZE, offset]
)
const data = rows as any[]
// 每 100 行生成一条 INSERT
const INSERT_BATCH = 100
for (let i = 0; i < data.length; i += INSERT_BATCH) {
const batch = data.slice(i, i + INSERT_BATCH)
const values = batch.map(row => {
const vals = columns.map(col => {
const v = row[col]
if (v === null) return 'NULL'
if (typeof v === 'number') return String(v)
if (typeof v === 'boolean') return v ? '1' : '0'
// mysql2 escape 处理所有字符串/日期转义(自带引号)
return pool.escape(v)
})
return `(${vals.join(', ')})`
@@ -68,6 +81,9 @@ export async function backupDatabase(): Promise<string> {
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
lines.push(` ${values};`)
}
offset += QUERY_BATCH_SIZE
}
lines.push('')
}