Compare commits

...

56 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
wangxiaogang
a35689cdda feat: 全面公告系统 + 全系统 Bug 修复
全面公告系统:
- notifications 表新增置顶/强提醒/定时发布/过期/配图/链接字段
- 后端增强:7 个接口(列表/置顶/未读数/已读/发布/编辑/删除)
- 首页强提醒公告弹窗
- 通知中心:置顶标记、配图展示、链接跳转、管理员操作
- 管理员发布表单:标题+内容+链接+配图+置顶+强提醒+定时+过期

Bug 修复:
- 401 重试队列竞态导致请求永久挂起
- 多分类筛选条件被静默丢弃
- 网络错误导致群组选择丢失
- Numpad 输入 '.' 显示异常
- transaction store 错误时清空数据
- 首页/预算页 Promise.all 部分失败阻塞关键数据
- CSS prefers-reduced-motion 类名不匹配
- 通知 read-all 遗漏群组通知
- auth.ts 错误日志缺少 stack trace
- stats 页面 v-for key 使用 index
- add 页面重复调用 fetchCategories
2026-06-06 22:02:28 +08:00
wangxiaogang
83571de723 feat: 迭代三大模块 + 全系统 Bug 修复
新功能:
- 账单筛选统计:多维度筛选面板 + 保存方案 + 结果统计
- 通知公告:系统公告/群组通知/个人提醒 + 通知中心页面
- 管理员后台:数据看板 + 用户管理 + 发布公告

Bug 修复:
- 统计页 fetchPrevMonthData/fetchMaxSingle 添加 group_id
- 群组视图添加 t.group_id 过滤,防止私人数据泄露
- 通知列表添加群组通知条件
- 通知标记已读添加错误处理
- 管理员 API 添加 affectedRows 检查
- 筛选面板金额为 0 时正确回填
- profile onShow 补充 userStore.fetchUserInfo
- 全系统样式修复(overflow/box-sizing)
2026-06-06 17:55:29 +08:00
wangxiaogang
bc6eca1e9b feat: 全部图标替换为 Lucide 高质量 PNG (29个) 2026-06-06 15:25:15 +08:00
117 changed files with 11723 additions and 1288 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)
groupStore.fetchGroups()
console.log('[Auth] H5 demo login success')
}).catch((e) => {
console.error('[Auth] H5 demo login failed:', e)
}).finally(() => { markReady() })
loginWithRetry(demoLogin).then((success) => {
if (success) {
groupStore.fetchGroups()
console.log('[Auth] H5 demo login success')
} 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>

56
client/src/api/admin.ts Normal file
View File

@@ -0,0 +1,56 @@
import { request } from '@/utils/request'
/** 数据看板 */
export interface Dashboard {
totalUsers: number
monthlyTxCount: number
monthlyTxAmount: number
dailyActive: number
monthlyActive: number
growthPercent: number
lastMonthTxCount: number
lastMonthTxAmount: number
monthlyExpense?: number
monthlyIncome?: number
lastMonthExpense?: number
lastMonthIncome?: number
}
/** 用户信息(管理员视角) */
export interface AdminUser {
id: number
nickname: string
avatar_url: string
role: 'user' | 'admin'
created_at: string
tx_count: number
group_count: number
}
/** 用户列表响应 */
export interface AdminUserList {
list: AdminUser[]
total: number
page: number
pageSize: number
}
/** 获取数据看板 */
export function getDashboard() {
return request<Dashboard>({ url: '/admin/dashboard' })
}
/** 获取用户列表 */
export function getUsers(params?: { page?: number; pageSize?: number; keyword?: string }) {
return request<AdminUserList>({ url: '/admin/users', data: params })
}
/** 更新用户角色 */
export function updateUserRole(id: number, role: 'user' | 'admin') {
return request({ url: `/admin/users/${id}/status`, method: 'PUT', data: { role } })
}
/** 删除用户 */
export function deleteUser(id: number) {
return request({ url: `/admin/users/${id}`, method: 'DELETE' })
}

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 } })
}

35
client/src/api/filter.ts Normal file
View File

@@ -0,0 +1,35 @@
import { request } from '@/utils/request'
/** 保存的筛选方案 */
export interface SavedFilter {
id: number
name: string
filters: FilterParams
created_at: string
}
/** 筛选参数 */
export interface FilterParams {
type?: string
category_ids?: number[]
startDate?: string
endDate?: string
minAmount?: number
maxAmount?: number
keyword?: string
}
/** 获取保存的筛选方案 */
export function getSavedFilters() {
return request<SavedFilter[]>({ url: '/filters' })
}
/** 保存筛选方案 */
export function saveFilter(data: { name: string; filters: FilterParams }) {
return request<{ id: number }>({ url: '/filters', method: 'POST', data })
}
/** 删除筛选方案 */
export function deleteFilter(id: number) {
return request({ url: `/filters/${id}`, method: 'DELETE' })
}

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

@@ -0,0 +1,117 @@
import { request } from '@/utils/request'
import { API_BASE } from '@/config'
/** 通知 */
export interface Notification {
id: number
type: 'system' | 'group' | 'personal'
title: string
content: string
is_read: number
created_at: string
group_id?: number | null
is_pinned?: number
is_urgent?: number
publish_at?: string | null
expire_at?: string | null
image_url?: string
link_url?: string
}
/** 通知列表响应 */
export interface NotificationList {
list: Notification[]
total: number
page: number
pageSize: number
}
/** 发布公告参数 */
export interface PublishParams {
title: string
content?: string
type?: 'system' | 'group' | 'personal'
group_id?: number
is_pinned?: boolean
is_urgent?: boolean
publish_at?: string
expire_at?: string
image_url?: string
link_url?: string
}
/** 获取通知列表 */
export function getNotifications(params?: { type?: string; page?: number; pageSize?: number }) {
return request<NotificationList>({ url: '/notifications', data: params })
}
/** 获取置顶/强提醒公告 */
export function getPinnedNotifications() {
return request<Notification[]>({ url: '/notifications/pinned' })
}
/** 获取未读数量 */
export function getUnreadCount() {
return request<{ count: number }>({ url: '/notifications/unread-count' })
}
/** 标记单条已读 */
export function markRead(id: number) {
return request({ url: `/notifications/${id}/read`, method: 'PUT' })
}
/** 全部标记已读 */
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 })
}
/** 编辑公告 */
export function updateNotification(id: number, data: Partial<PublishParams>) {
return request({ url: `/notifications/${id}`, method: 'PUT', data })
}
/** 删除公告 */
export function deleteNotification(id: number) {
return request({ url: `/notifications/${id}`, method: 'DELETE' })
}

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

@@ -23,6 +23,8 @@ export interface TransactionList {
total: number
page: number
pageSize: number
filteredExpense?: number
filteredIncome?: number
}
/** 获取交易列表参数 */
@@ -34,6 +36,10 @@ export interface TransactionParams {
endDate?: string
sortBy?: string
group_id?: number | null
category_id?: number
minAmount?: number
maxAmount?: number
keyword?: string
}
/** 获取交易列表 */

View File

@@ -6,6 +6,8 @@ export interface UserInfo {
id: number
nickname: string
avatar_url: string
slogan: string
role: 'user' | 'admin'
created_at: string
}
@@ -14,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

@@ -63,6 +63,14 @@ const iconMap: Record<string, Record<string, string>> = {
'#8B7E7E': '/static/icons/refresh-gray.png',
'#FF8C69': '/static/icons/refresh-orange.png',
},
filter: {
'#8B7E7E': '/static/icons/filter-gray.png',
'#FF8C69': '/static/icons/filter-orange.png',
},
settings: {
'#8B7E7E': '/static/icons/settings-gray.png',
'#FF8C69': '/static/icons/settings-orange.png',
},
}
const iconSrc = computed(() => {

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) { emitValue('0.'); return }
}
let next: string
if (!current || current === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = current + key
}
if (next.includes('.')) {
const decimals = next.split('.')[1]
if (decimals && decimals.length > 2) return
}
if (next.length > 10) return
const num = parseFloat(next)
if (!isNaN(num) && num > props.max) return
emitValue(next)
}
function onDelete() {
vibrate()
const current = amountStr.value
if (current.length > 1) {
emitValue(current.slice(0, -1))
} else {
emitValue('')
const result = applyAmountKey(
amountStr.value,
effectiveCursorIndex.value,
key,
{ max: props.max }
)
if (result.accepted) {
emit('update:modelValue', result.value)
emit('update:cursorIndex', result.cursorIndex)
}
}
function onKeyTap(key: AmountKey) {
applyKey(key)
}
function onDeleteTap() {
if (deleteLongPressed) {
deleteLongPressed = false
return
}
applyKey('delete')
}
function doDeleteRepeat() {
deleteLongPressed = true
const result = applyAmountKey(
amountStr.value,
effectiveCursorIndex.value,
'delete',
{ max: props.max }
)
if (result.accepted) {
vibrate()
emit('update:modelValue', result.value)
emit('update:cursorIndex', result.cursorIndex)
deleteTimer = setTimeout(doDeleteRepeat, 120)
} else {
deleteLongPressed = false
deleteTimer = null
}
}
function onDeleteTouchStart() {
if (deleteTimer !== null) clearTimeout(deleteTimer)
deleteLongPressed = false
deleteTimer = setTimeout(doDeleteRepeat, 400)
}
function onDeleteTouchEnd() {
if (deleteTimer !== null) {
clearTimeout(deleteTimer)
deleteTimer = null
}
}
onBeforeUnmount(() => {
if (deleteTimer !== null) {
clearTimeout(deleteTimer)
deleteTimer = null
}
})
function onConfirm() {
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 }">
<Icon name="check" :size="80" color="#FFFFFF" />
<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">{{ 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>
<text class="success-text" :class="{ show: animating }">{{ text }}</text>
</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;
.success-icon {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: linear-gradient(135deg, $success, #5BA85E);
@include flex-center;
}
&.show {
transform: translateY(0);
opacity: 1;
.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,86 @@
"path": "pages/group-manage/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "一起记"
"navigationBarTitleText": "一起记",
"enablePullDownRefresh": true
}
},
{
"path": "pages/notifications/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "通知中心",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "管理后台",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin/users",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "用户管理",
"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">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="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()
await catStore.fetchCategories()
// 加载分类列表,失败时重试一次
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' })
@@ -149,17 +214,23 @@ onMounted(async () => {
if (currentCategories.value.length > 0 && !selectedCat.value) {
selectedCat.value = currentCategories.value[0].id
}
initialLoaded.value = true
})
// 返回页面时刷新分类(可能新增了分类)
const initialLoaded = ref(false)
onShow(() => {
catStore.fetchCategories()
if (initialLoaded.value) catStore.fetchCategories()
})
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)
@@ -187,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 {
@@ -195,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
@@ -214,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;
}
@@ -268,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;
@@ -332,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 {
@@ -342,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;
@@ -362,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

@@ -0,0 +1,302 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
</view>
<text class="nav-title">管理后台</text>
<view class="nav-placeholder"></view>
</view>
</view>
<view v-if="loading" class="loading-box">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="dashboard" class="content">
<!-- 指标卡 -->
<view class="metric-grid">
<view class="metric-card">
<text class="metric-value">{{ dashboard.totalUsers }}</text>
<text class="metric-label">总用户</text>
</view>
<view class="metric-card">
<text class="metric-value">{{ dashboard.monthlyTxCount }}</text>
<text class="metric-label">本月交易</text>
</view>
<view class="metric-card">
<text class="metric-value">{{ dashboard.dailyActive }}</text>
<text class="metric-label">今日活跃</text>
</view>
<view class="metric-card">
<text class="metric-value" :class="dashboard.growthPercent >= 0 ? 'up' : 'down'">
{{ dashboard.growthPercent >= 0 ? '+' : '' }}{{ dashboard.growthPercent }}%
</text>
<text class="metric-label">环比增长</text>
</view>
</view>
<!-- 本月详情 -->
<view class="detail-card">
<text class="detail-title">本月概况</text>
<view class="detail-row">
<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">{{ formatAmount(dashboard.lastMonthExpense || 0) }}</text>
</view>
</view>
<!-- 功能入口 -->
<view class="section-title">管理功能</view>
<view class="entry-list">
<view class="entry-item" @tap="goUsers">
<Icon name="user" :size="32" color="#FF8C69" />
<text class="entry-text">用户管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="entry-item" @tap="goNotificationManage">
<Icon name="bell" :size="32" color="#FF8C69" />
<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="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 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>
</view>
</template>
<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 { formatAmount } from '@/utils/format'
import Icon from '@/components/Icon/Icon.vue'
import type { Dashboard } from '@/api/admin'
const loading = ref(true)
const dashboard = ref<Dashboard | null>(null)
onMounted(async () => {
await waitForReady()
try {
dashboard.value = await getDashboard()
} catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
})
// 静默刷新
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' }) }
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 {
@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-placeholder { width: 88rpx; }
.loading-box {
display: flex;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.loading-text {
font-size: $font-lg;
color: $text-muted;
}
.content { padding: 0 $space-xl; }
.metric-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $space-sm;
margin-bottom: $space-lg;
}
.metric-card {
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: $font-3xl;
font-weight: 600;
color: $text;
display: block;
&.up { color: $success; }
&.down { color: $danger; }
}
.metric-label {
font-size: $font-md;
color: $text-sec;
display: block;
margin-top: $space-xs;
}
.detail-card {
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: $font-lg;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-lg;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 0;
border-bottom: 2rpx solid #F8F0EB;
&:last-child { border-bottom: none; }
}
.detail-label {
font-size: $font-base;
color: $text-sec;
}
.detail-value {
font-family: 'Fredoka', sans-serif;
font-size: $font-base;
font-weight: 500;
color: $text;
&.expense { color: $danger; }
&.income { color: $success; }
}
.section-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
margin-bottom: $space-sm;
}
.entry-list { margin-bottom: $space-lg; }
.entry-item {
display: flex;
align-items: center;
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: $bg; }
}
.entry-text {
flex: 1;
font-size: $font-lg;
font-weight: 500;
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

@@ -0,0 +1,323 @@
<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="search-bar">
<input class="search-input" v-model="keyword" placeholder="搜索昵称" @confirm="loadUsers(true)" />
</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="user" :size="48" color="#BFB3B3" />
<text class="empty-text">暂无用户</text>
</view>
<view v-else class="user-list">
<view v-for="user in list" :key="user.id" class="user-item">
<image class="user-avatar" :src="getAvatarUrl(user.avatar_url)" mode="aspectFill" />
<view class="user-info">
<view class="user-name-row">
<text class="user-name">{{ user.nickname || '用户' + user.id }}</text>
<view class="role-tag" :style="{ background: user.role === 'admin' ? '#FFE8E0' : '#F0E0D6' }">
<text class="role-tag-text" :style="{ color: user.role === 'admin' ? '#FF8C69' : '#8B7E7E' }">{{ user.role === 'admin' ? '管理员' : '用户' }}</text>
</view>
</view>
<text class="user-meta">{{ user.tx_count }} 笔记录 · {{ user.group_count }} 个群组</text>
<text class="user-time">注册于 {{ formatDate(user.created_at) }}</text>
</view>
<view class="user-actions">
<view class="action-btn" @tap="handleToggleRole(user)">
<text class="action-text">{{ user.role === 'admin' ? '取消管理' : '设为管理' }}</text>
</view>
<view class="action-btn danger" @tap="handleDelete(user)">
<text class="action-text danger-text">删除</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { API_BASE } from '@/config'
import { getUsers, updateUserRole, deleteUser } from '@/api/admin'
import { useUserStore } from '@/stores/user'
import Icon from '@/components/Icon/Icon.vue'
import type { AdminUser } from '@/api/admin'
const userStore = useUserStore()
const keyword = ref('')
const list = ref<AdminUser[]>([])
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) {
if (reset) {
page.value = 1
list.value = []
}
loading.value = true
try {
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) {
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 getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
function formatDate(dateStr: string) {
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function handleToggleRole(user: AdminUser) {
if (user.id === userStore.userInfo?.id) {
uni.showToast({ title: '不能修改自己的角色', icon: 'none' })
return
}
const newRole = user.role === 'admin' ? 'user' : 'admin'
const label = newRole === 'admin' ? '设为管理员' : '取消管理员'
uni.showModal({
title: label,
content: `确定${label}${user.nickname}」?`,
success: async (res) => {
if (res.confirm) {
try {
await updateUserRole(user.id, newRole)
user.role = newRole
uni.showToast({ title: '已更新', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
}
})
}
function handleDelete(user: AdminUser) {
uni.showModal({
title: '删除用户',
content: `确定删除「${user.nickname}」?该用户的所有数据将被清除。`,
success: async (res) => {
if (res.confirm) {
try {
await deleteUser(user.id)
list.value = list.value.filter(u => u.id !== user.id)
uni.showToast({ title: '已删除', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '删除失败', icon: 'none' })
}
}
}
})
}
function goBack() { uni.navigateBack() }
onReachBottom(() => {
if (list.value.length >= total.value || loading.value) return
page.value++
loadUsers(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-placeholder { width: 88rpx; }
.search-bar {
padding: 0 $space-xl $space-md;
}
.search-input {
height: 72rpx;
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: $space-sm;
padding: 120rpx 0;
}
.loading-text, .empty-text {
font-size: $font-lg;
color: $text-muted;
}
.user-list { padding: 0 $space-xl; }
.user-item {
display: flex;
align-items: flex-start;
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;
}
.user-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: $border;
flex-shrink: 0;
}
.user-info { flex: 1; min-width: 0; }
.user-name-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.user-name {
font-size: $font-lg;
font-weight: 600;
color: $text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.role-tag {
padding: 2rpx 12rpx;
border-radius: $radius-xs;
}
.role-tag-text {
font-size: $font-xs;
font-weight: 500;
}
.user-meta {
font-size: $font-md;
color: $text-sec;
display: block;
margin-top: 4rpx;
}
.user-time {
font-size: $font-sm;
color: $text-muted;
display: block;
margin-top: 4rpx;
}
.user-actions {
display: flex;
flex-direction: column;
gap: $space-xs;
flex-shrink: 0;
}
.action-btn {
padding: $space-xs $space-sm;
background: $surface-warm;
border-radius: $radius-sm;
text-align: center;
&.danger { background: $danger-light; }
&:active { opacity: 0.7; }
}
.action-text {
font-size: $font-sm;
color: $primary;
&.danger-text { color: $danger; }
}
</style>

View File

@@ -0,0 +1,511 @@
<template>
<view class="filter-mask" @tap="$emit('close')">
<view class="filter-panel" @tap.stop>
<view class="filter-header">
<text class="filter-title">筛选</text>
<view class="filter-close" @tap="$emit('close')">
<text class="filter-close-text">×</text>
</view>
</view>
<scroll-view class="filter-body" scroll-y>
<!-- 分类 -->
<view class="filter-section">
<text class="section-label">分类</text>
<view class="chip-row">
<view
v-for="cat in categories"
:key="cat.id"
class="chip"
:class="{ active: localFilters.category_ids?.includes(cat.id) }"
@tap="toggleCategory(cat.id)"
>
<text class="chip-text">{{ cat.name }}</text>
</view>
</view>
</view>
<!-- 日期范围 -->
<view class="filter-section">
<text class="section-label">日期范围</text>
<view class="date-row">
<picker
class="date-picker"
mode="date"
:value="localFilters.startDate || ''"
@change="onStartDateChange"
>
<text
class="date-text"
:class="{ placeholder: !localFilters.startDate }"
>{{ localFilters.startDate || "开始日期" }}
</text>
</picker>
<text class="date-sep"></text>
<picker
class="date-picker"
mode="date"
:value="localFilters.endDate || ''"
@change="onEndDateChange"
>
<text
class="date-text"
:class="{ placeholder: !localFilters.endDate }"
>{{ localFilters.endDate || "结束日期" }}
</text>
</picker>
</view>
</view>
<!-- 金额区间 -->
<view class="filter-section">
<text class="section-label">金额区间</text>
<view class="amount-row">
<input
class="amount-input"
type="digit"
v-model="minAmountStr"
placeholder="最小金额"
/>
<text class="amount-sep">-</text>
<input
class="amount-input"
type="digit"
v-model="maxAmountStr"
placeholder="最大金额"
/>
</view>
</view>
<!-- 关键词 -->
<view class="filter-section">
<text class="section-label">搜索备注</text>
<input
class="keyword-input"
v-model="localFilters.keyword"
placeholder="输入关键词"
/>
</view>
<!-- 已保存方案 -->
<view class="filter-section" v-if="savedFilters.length > 0">
<text class="section-label">已保存方案</text>
<view
v-for="sf in savedFilters"
:key="sf.id"
class="saved-item"
@tap="applySaved(sf)"
>
<text class="saved-name">{{ sf.name }}</text>
<view class="saved-delete" @tap.stop="handleDeleteSaved(sf.id)">
<text class="saved-delete-text">×</text>
</view>
</view>
</view>
</scroll-view>
<view class="filter-footer">
<view class="footer-btn reset" @tap="handleReset">
<text class="footer-btn-text reset-text">重置</text>
</view>
<view class="footer-btn save" @tap="handleSave">
<text class="footer-btn-text save-text">保存</text>
</view>
<view class="footer-btn apply" @tap="handleApply">
<text class="footer-btn-text apply-text">应用</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue";
import { useCategoryStore } from "@/stores/category";
import { useFilterStore } from "@/stores/filter";
import type { FilterParams, SavedFilter } from "@/api/filter";
const emit = defineEmits<{
close: [];
apply: [filters: FilterParams];
}>();
const props = defineProps<{
initialFilters?: FilterParams;
}>();
const categoryStore = useCategoryStore();
const filterStore = useFilterStore();
const categories = ref<any[]>([]);
const savedFilters = ref<SavedFilter[]>([]);
const minAmountStr = ref("");
const maxAmountStr = ref("");
const localFilters = reactive<FilterParams>({
type: "",
category_ids: [],
startDate: "",
endDate: "",
minAmount: 0,
maxAmount: 0,
keyword: "",
});
onMounted(async () => {
initCategorys();
initSavedFilters();
// 应用初始筛选条件
if (props.initialFilters) {
Object.assign(localFilters, props.initialFilters);
if (localFilters.minAmount != null && localFilters.minAmount > 0)
minAmountStr.value = (localFilters.minAmount / 100).toString();
if (localFilters.maxAmount != null && localFilters.maxAmount > 0)
maxAmountStr.value = (localFilters.maxAmount / 100).toString();
}
});
const initCategorys = async () => {
await categoryStore.fetchCategories();
categories.value = [
...categoryStore.getByType("expense"),
...categoryStore.getByType("income"),
];
};
const initSavedFilters = async () => {
await filterStore.fetchSavedFilters();
savedFilters.value = filterStore.savedFilters;
};
function toggleCategory(id: number) {
if (!localFilters.category_ids) localFilters.category_ids = [];
const idx = localFilters.category_ids.indexOf(id);
if (idx >= 0) {
localFilters.category_ids.splice(idx, 1);
} else {
localFilters.category_ids.push(id);
}
}
function onStartDateChange(e: any) {
localFilters.startDate = e.detail.value;
}
function onEndDateChange(e: any) {
localFilters.endDate = e.detail.value;
}
function handleReset() {
Object.assign(localFilters, {
type: "",
category_ids: [],
startDate: "",
endDate: "",
minAmount: 0,
maxAmount: 0,
keyword: "",
});
minAmountStr.value = "";
maxAmountStr.value = "";
}
function handleApply() {
const filters: FilterParams = { ...localFilters };
// 转换金额:元 → 分,防御 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 };
// 转换金额:元 → 分,防御 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: "保存方案",
editable: true,
placeholderText: "输入方案名称",
success: async (res) => {
if (res.confirm && res.content) {
try {
await filterStore.saveFilter(res.content.trim(), filters);
savedFilters.value = filterStore.savedFilters;
uni.showToast({ title: "已保存", icon: "success" });
} catch (e: any) {
uni.showToast({ title: e.message || "保存失败", icon: "none" });
}
}
},
});
}
function applySaved(sf: SavedFilter) {
Object.assign(localFilters, sf.filters);
minAmountStr.value =
sf.filters.minAmount != null && sf.filters.minAmount > 0
? (sf.filters.minAmount / 100).toString()
: "";
maxAmountStr.value =
sf.filters.maxAmount != null && sf.filters.maxAmount > 0
? (sf.filters.maxAmount / 100).toString()
: "";
}
async function handleDeleteSaved(id: number) {
try {
await filterStore.deleteFilter(id);
savedFilters.value = filterStore.savedFilters;
} catch {}
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.filter-mask {
@include modal-mask;
}
.filter-panel {
width: 100%;
max-height: 80vh;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.filter-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
.filter-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.filter-close {
width: 56rpx;
height: 56rpx;
@include flex-center;
border-radius: 50%;
background: $border;
&:active {
background: darken($border, 5%);
}
}
.filter-close-text {
font-size: $font-2xl;
color: $text-sec;
line-height: 1;
}
.filter-body {
flex: 1;
padding: $space-md $space-xl;
max-height: 55vh;
overflow-x: hidden;
}
.filter-section {
margin-bottom: $space-lg;
width: 100%;
}
.section-label {
font-size: $font-base;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-sm;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: $space-xs;
width: 100%;
}
.chip {
padding: $space-xs $space-md;
background: $bg;
border-radius: $radius-xl;
border: 2rpx solid $border;
transition: all $transition-normal;
&.active {
background: $primary-light;
border-color: $primary;
}
&:active {
opacity: 0.7;
}
}
.chip-text {
font-size: $font-md;
color: $text;
.active & {
color: $primary;
font-weight: 600;
}
}
.date-row {
display: flex;
align-items: center;
gap: $space-sm;
width: 100%;
}
.date-picker {
flex: 1;
height: 72rpx;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
display: flex;
align-items: center;
}
.date-text {
font-size: $font-base;
color: $text;
&.placeholder {
color: $text-muted;
}
}
.date-sep {
font-size: $font-base;
color: $text-sec;
}
.amount-row {
display: flex;
align-items: center;
gap: $space-sm;
width: 100%;
}
.amount-input {
flex: 1;
height: 72rpx;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-base;
color: $text;
}
.amount-sep {
font-size: $font-base;
color: $text-sec;
}
.keyword-input {
height: 72rpx;
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: $space-sm $space-md;
background: $bg;
border-radius: $radius-lg;
margin-bottom: $space-xs;
&:active {
background: $border;
}
}
.saved-name {
font-size: $font-base;
color: $text;
}
.saved-delete {
width: 40rpx;
height: 40rpx;
@include flex-center;
&:active {
opacity: 0.6;
}
}
.saved-delete-text {
font-size: $font-lg;
color: $text-muted;
}
.filter-footer {
display: flex;
gap: $space-sm;
padding: $space-md $space-xl $space-xl;
border-top: 2rpx solid $border;
}
.footer-btn {
flex: 1;
height: 80rpx;
border-radius: $radius-xl;
@include flex-center;
&.reset {
background: $bg;
border: 2rpx solid $border;
}
&.save {
background: $surface-warm;
border: 2rpx solid $primary;
}
&.apply {
background: linear-gradient(135deg, $primary, $primary-dark);
}
&:active {
opacity: 0.8;
}
}
.footer-btn-text {
font-size: $font-lg;
font-weight: 600;
&.reset-text {
color: $text-sec;
}
&.save-text {
color: $primary;
}
&.apply-text {
color: $surface;
}
}
</style>

View File

@@ -11,9 +11,23 @@
<view class="tab" :class="{ active: filterType === 'expense' }" @tap="switchFilter('expense')">支出</view>
<view class="tab" :class="{ active: filterType === 'income' }" @tap="switchFilter('income')">收入</view>
</view>
<view class="filter-btn" :class="{ active: hasAdvancedFilter }" @tap="showFilter = true">
<Icon name="filter" :size="28" :color="hasAdvancedFilter ? '#FF8C69' : '#8B7E7E'" />
<text class="filter-btn-text">筛选</text>
</view>
</view>
<view class="tx-list" v-if="!txStore.loading || txList.length > 0">
<!-- 筛选结果统计 -->
<view class="filter-stats" v-if="hasAdvancedFilter && !loading">
<text class="filter-stats-text"> {{ total }} </text>
<text class="filter-stats-text" v-if="filteredExpense > 0">支出 {{ formatAmount(filteredExpense) }}</text>
<text class="filter-stats-text" v-if="filteredIncome > 0">收入 {{ formatAmount(filteredIncome) }}</text>
</view>
<!-- 筛选面板 -->
<FilterPanel v-if="showFilter" :initialFilters="advancedFilters" @close="showFilter = false" @apply="onFilterApply" />
<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>
@@ -35,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" />
@@ -52,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>
@@ -65,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>
@@ -74,27 +89,40 @@
<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 // 请求序号,防止并发覆盖
// 高级筛选
const showFilter = ref(false)
const advancedFilters = ref<FilterParams>({})
const filteredExpense = ref(0)
const filteredIncome = ref(0)
const hasAdvancedFilter = computed(() => {
const f = advancedFilters.value
return !!(f.category_ids?.length || f.startDate || f.endDate || f.minAmount || f.maxAmount || f.keyword)
})
const noMore = computed(() => txList.value.length >= total.value && total.value > 0)
const groupedTx = computed(() => {
@@ -136,26 +164,45 @@ 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
if (af.category_ids?.length) params.category_ids = af.category_ids.join(',')
if (af.startDate) params.startDate = af.startDate
if (af.endDate) params.endDate = af.endDate
if (af.minAmount) params.minAmount = af.minAmount
if (af.maxAmount) params.maxAmount = af.maxAmount
if (af.keyword) params.keyword = af.keyword
try {
loading.value = true
loadError.value = false
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
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
}
}
function onFilterApply(filters: FilterParams) {
advancedFilters.value = filters
showFilter.value = false
loadTx(true)
}
function onEdit(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '只能编辑自己的记录', icon: 'none' })
@@ -175,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' })
@@ -189,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)
})
@@ -201,124 +254,129 @@ 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; justify-content: center; margin: 0 0 32rpx; }
.tabs-wrap {
@include tabs-wrap;
justify-content: center;
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;
}
.tx-list { padding: 0 40rpx; }
.filter-btn {
display: flex;
align-items: center;
gap: 6rpx;
padding: 12rpx 20rpx;
background: $surface-warm;
border-radius: $radius-lg;
border: 2rpx solid $border;
&.active {
border-color: $primary;
background: $primary-light;
}
&:active { opacity: 0.7; }
}
.filter-btn-text {
font-size: $font-md;
color: $text-sec;
.active & { color: $primary; }
}
.filter-stats {
display: flex;
gap: $space-md;
padding: 0 $space-xl $space-sm;
}
.filter-stats-text {
font-size: $font-md;
font-family: 'Fredoka', sans-serif;
color: $text-sec;
}
.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>
</view>
<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]
@@ -116,19 +128,22 @@ function syncBudgetData() {
/** 获取个人支出(不传 group_id始终只算自己的消费 */
async function fetchMyExpense() {
const data = await getOverview({ month: currentMonth, group_id: null })
monthExpense.value = Number(data?.expense) || 0
try {
const data = await getOverview({ month: currentMonth, group_id: null })
monthExpense.value = Number(data?.expense) || 0
} catch {
monthExpense.value = 0
}
}
onMounted(async () => {
await waitForReady()
loading.value = true
try {
await Promise.all([
budgetStore.fetchBudget(currentMonth),
fetchMyExpense()
])
await budgetStore.fetchBudget(currentMonth)
syncBudgetData()
// 非关键:支出数据失败不影响预算显示
fetchMyExpense()
} catch (e) {
console.error('Budget page load error:', e)
} finally {
@@ -141,16 +156,24 @@ onMounted(async () => {
onShow(async () => {
if (!initialLoaded.value) return
try {
await Promise.all([
budgetStore.fetchBudget(currentMonth),
fetchMyExpense()
])
await budgetStore.fetchBudget(currentMonth)
syncBudgetData()
fetchMyExpense()
} 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() {
@@ -179,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 {
@@ -226,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 {
@@ -246,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);
.input-prefix {
font-family: 'Fredoka', sans-serif;
font-size: 40rpx;
font-weight: 600;
color: #FF8C69;
}
.input-value {
font-family: 'Fredoka', sans-serif;
font-size: 56rpx;
font-weight: 600;
color: #2D1B1B;
flex: 1;
&.placeholder {
color: #D0C4C4;
&:active {
opacity: 0.8;
transform: scale(0.98);
}
}
.input-unit {
font-size: 28rpx;
color: #8B7E7E;
.save-btn-text {
font-size: $font-xl;
font-weight: 600;
color: $surface;
}
.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;
}
.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

@@ -62,11 +62,8 @@
<view class="group-info">
<view class="group-name-row">
<text class="group-name">{{ group.name }}</text>
<view v-if="group.role === 'owner'" class="role-badge owner">
<text class="role-badge-text">群主</text>
</view>
<view v-else class="role-badge member">
<text class="role-badge-text">成员</text>
<view class="role-badge" :style="{ background: group.role === 'owner' ? '#FFE8E0' : '#F0E0D6' }">
<text class="role-badge-text" :style="{ color: group.role === 'owner' ? '#FF8C69' : '#8B7E7E' }">{{ group.role === 'owner' ? '群主' : '成员' }}</text>
</view>
</view>
<text class="group-meta">{{ group.member_count }} </text>
@@ -78,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>
@@ -122,7 +124,9 @@
<image class="member-avatar" :src="getAvatarUrl(m.avatar_url)" mode="aspectFill" />
<view class="member-info">
<text class="member-name">{{ m.nickname || '用户' + m.user_id }}</text>
<text class="member-role" :class="m.role">{{ m.role === 'owner' ? '群主' : '成员' }}</text>
<view class="member-role-wrap">
<text class="member-role-text" :style="{ color: m.role === 'owner' ? '#FF8C69' : '#BFB3B3' }">{{ m.role === 'owner' ? '群主' : '成员' }}</text>
</view>
</view>
<view v-if="m.role !== 'owner' && currentGroup?.role === 'owner'" class="member-remove" @tap="handleRemoveMember(m)">
<text class="member-remove-text">移除</text>
@@ -136,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'
@@ -145,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)
@@ -169,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()
}
@@ -326,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 {
@@ -387,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 {
@@ -457,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;
@@ -506,30 +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;
&.owner { background: #FFE8E0; }
&.member { background: #F0E0D6; }
border-radius: $radius-xs;
}
.role-badge-text {
font-size: 20rpx;
font-size: $font-xs;
font-weight: 500;
.owner & { color: #FF8C69; }
.member & { color: #8B7E7E; }
}
.group-meta {
font-size: 24rpx;
color: #BFB3B3;
font-size: $font-md;
color: $text-muted;
display: block;
margin-top: 4rpx;
}
@@ -539,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;
}
@@ -560,83 +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;
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 {
@@ -646,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;
}
@@ -666,15 +689,17 @@ function handleDissolve(group: Group) {
.member-list {
max-height: 60vh;
padding: 16rpx 40rpx 40rpx;
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%;
&:last-child { border-bottom: none; }
}
@@ -683,7 +708,7 @@ function handleDissolve(group: Group) {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #F0E0D6;
background: $border;
flex-shrink: 0;
}
@@ -693,34 +718,36 @@ 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;
white-space: nowrap;
}
.member-role {
font-size: 22rpx;
display: block;
.member-role-wrap {
margin-top: 4rpx;
}
&.owner { color: #FF8C69; }
&.member { color: #BFB3B3; }
.member-role-text {
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

@@ -8,8 +8,11 @@
<Icon v-else name="user" :size="36" color="#8B7E7E" />
</view>
<text class="greeting">{{ greeting }}{{ userStore.nickname }}</text>
<view class="bell" @tap="showNotification">
<view class="bell" @tap="goNotifications">
<Icon name="bell" :size="40" color="#8B7E7E" />
<view v-if="notifStore.unreadCount > 0" class="bell-badge">
<text class="bell-badge-text">{{ notifStore.unreadCount > 99 ? '99+' : notifStore.unreadCount }}</text>
</view>
</view>
</view>
</view>
@@ -109,6 +112,26 @@
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">还没有记录快去记一笔吧</text>
</view>
<!-- 强提醒公告弹窗 -->
<view class="modal-mask" v-if="urgentNotice" @tap="dismissUrgent">
<view class="urgent-modal" @tap.stop>
<view class="urgent-header">
<Icon name="bell" :size="32" color="#FF8C69" />
<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="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>
</view>
</scroll-view>
<view class="urgent-footer" @tap="dismissUrgent">
<text class="urgent-btn-text">我知道了</text>
</view>
</view>
</view>
</view>
</template>
@@ -120,8 +143,12 @@ import { useStatsStore } from '@/stores/stats'
import { useBudgetStore } from '@/stores/budget'
import { useUserStore } from '@/stores/user'
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'
@@ -134,12 +161,13 @@ const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const userStore = useUserStore()
const groupStore = useGroupStore()
const notifStore = useNotificationStore()
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)
@@ -155,21 +183,54 @@ const greeting = computed(() => {
return '晚上好'
})
// 强提醒公告:取第一条未读的
const urgentNotice = computed(() => notifStore.urgentNotifications[0] || null)
function dismissUrgent() {
if (urgentNotice.value) {
notifStore.markRead(urgentNotice.value.id)
}
}
function openLink(url: string) {
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifdef MP-WEIXIN
uni.setClipboardData({
data: url,
success: () => uni.showToast({ title: '链接已复制', icon: 'success' })
})
// #endif
}
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),
userStore.fetchUserInfo()
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(),
syncRecurring().catch(() => {})
]).catch(() => {})
} catch (e) {
console.error('Load error:', e)
if (!silent) loadError.value = true
@@ -178,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
@@ -209,8 +272,8 @@ function goAdd(type: string) {
uni.navigateTo({ url: `/pages/add/index?type=${type}` })
}
function showNotification() {
uni.showToast({ title: '暂无通知', icon: 'none' })
function goNotifications() {
uni.navigateTo({ url: '/pages/notifications/index' })
}
function onTxTap(item: any) {
@@ -227,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;
}
@@ -269,50 +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: $space-xs;
right: $space-xs;
min-width: 32rpx;
height: 32rpx;
padding: 0 $space-xs;
background: $danger;
border-radius: $radius-full;
@include flex-center;
}
.bell-badge-text {
font-size: $font-xs;
font-weight: 600;
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;
}
@@ -320,173 +391,227 @@ 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 {
@include modal-mask-high;
}
.urgent-modal {
width: 80%;
max-height: 70vh;
background: $surface;
border-radius: $radius-2xl;
overflow: hidden;
display: flex;
flex-direction: column;
}
.urgent-header {
display: flex;
align-items: center;
gap: $space-sm;
padding: $space-xl $space-lg $space-md;
}
.urgent-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
flex: 1;
}
.urgent-body {
flex: 1;
padding: 0 $space-lg $space-lg;
max-height: 50vh;
}
.urgent-image {
width: 100%;
border-radius: $radius-md;
margin-bottom: $space-sm;
}
.urgent-content {
font-size: $font-lg;
color: $text;
line-height: 1.6;
}
.urgent-link {
margin-top: $space-sm;
padding: $space-xs 0;
}
.urgent-link-text {
font-size: $font-lg;
color: $primary;
text-decoration: underline;
}
.urgent-footer {
padding: $space-lg;
border-top: 2rpx solid $border;
text-align: center;
&:active { background: $surface-warm; }
}
.urgent-btn-text {
font-size: 30rpx;
font-weight: 600;
color: #FF8C69;
}
</style>

View File

@@ -0,0 +1,518 @@
<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="handleMarkAllRead">
<text class="nav-action-text">全部已读</text>
</view>
</view>
</view>
<!-- Tab 切换 -->
<view class="tabs-wrap">
<view class="tabs">
<view v-for="tab in tabs" :key="tab.key" class="tab" :class="{ active: currentTab === tab.key }" @tap="switchTab(tab.key)">
<text class="tab-text">{{ tab.label }}</text>
</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>
<view v-else class="notification-list">
<view v-for="item in list" :key="item.id" class="notification-item" :class="{ unread: !item.is_read, pinned: item.is_pinned }" @tap="handleTap(item)">
<view class="notif-icon" :style="{ background: getNotifBg(item.type) }">
<Icon :name="getIconName(item.type)" :size="28" :color="getIconColor(item.type)" />
</view>
<view class="notif-content">
<view class="notif-title-row">
<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="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>
<view v-if="!item.is_read" class="unread-dot"></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="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, onPullDownRefresh } from '@dcloudio/uni-app'
import { useNotificationStore } from '@/stores/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 tabs = [
{ key: 'all', label: '全部' },
{ key: 'system', label: '公告' },
{ key: 'group', label: '群组' },
{ key: 'personal', label: '个人' },
]
const currentTab = ref('all')
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)
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) {
currentTab.value = key
loadNotifications(true)
}
async function loadNotifications(reset = false) {
if (reset) {
page.value = 1
list.value = []
}
const seq = ++loadSeq
loading.value = true
try {
const params: any = { page: page.value, pageSize: 20 }
if (currentTab.value !== 'all') params.type = currentTab.value
const data = await getNotifications(params)
if (seq !== loadSeq) return
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 {
if (seq === loadSeq) loading.value = false
}
}
function getIconName(type: string) {
if (type === 'system') return 'bell'
if (type === 'group') return 'user'
return 'alert-circle'
}
function getIconColor(type: string) {
if (type === 'system') return '#FF8C69'
if (type === 'group') return '#8B7E7E'
return '#BFB3B3'
}
function getNotifBg(type: string) {
if (type === 'system') return '#FFE8E0'
if (type === 'group') return '#F0E0D6'
return '#FFF0E6'
}
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 handleTap(item: Notification) {
// 标记已读
if (!item.is_read) {
try {
await markRead(item.id)
item.is_read = 1
notifStore.fetchUnreadCount()
} catch {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
// 显示详情
detailItem.value = item
}
async function handleMarkAllRead() {
try {
await markAllRead()
list.value.forEach(item => { item.is_read = 1 })
notifStore.fetchUnreadCount()
uni.showToast({ title: '已全部标记已读', icon: 'success' })
} catch {
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
}
}
function openLink(url: string) {
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifdef MP-WEIXIN
uni.setClipboardData({
data: url,
success: () => uni.showToast({ title: '链接已复制', icon: 'success' })
})
// #endif
}
function goBack() {
uni.navigateBack()
}
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 {
padding: $space-xs $space-sm;
&:active { opacity: 0.6; }
}
.nav-action-text {
font-size: $font-base;
color: $primary;
}
.tabs-wrap { display: flex; justify-content: center; margin: 0 0 $space-md; }
.tabs {
display: inline-flex;
background: $surface-warm;
border-radius: $space-md;
padding: $space-xs;
}
.tab {
padding: 12rpx $space-lg;
border-radius: $radius-lg;
font-size: $font-base;
color: $text-sec;
transition: all $transition-normal;
&.active {
background: $surface;
color: $primary;
font-weight: 600;
box-shadow: $shadow-sm;
}
}
.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;
}
.notification-list { padding: 0 $space-xl; }
.notification-item {
display: flex;
align-items: flex-start;
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 $primary; }
}
.notif-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.notif-content { flex: 1; min-width: 0; }
.notif-title-row {
display: flex;
align-items: center;
gap: $space-xs;
}
.pin-badge {
font-size: $font-xs;
color: $primary;
background: #FFE8E0;
padding: 2rpx 10rpx;
border-radius: $radius-xs;
font-weight: 500;
}
.notif-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
display: block;
}
.notif-body {
font-size: $font-md;
color: $text-sec;
display: block;
margin-top: $space-xs;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notif-image {
width: 100%;
border-radius: $radius-md;
margin-top: $space-sm;
}
.notif-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: $space-xs;
}
.notif-time {
font-size: $font-sm;
color: $text-muted;
}
.unread-dot {
width: $space-sm;
height: $space-sm;
border-radius: 50%;
background: $primary;
flex-shrink: 0;
margin-top: $space-xs;
}
.notif-link {
font-size: $font-sm;
color: $primary;
text-decoration: underline;
}
.pinned {
border-color: $primary;
background: #FFFAF7;
}
.no-more {
text-align: center;
padding: $space-lg 0;
}
.no-more-text {
font-size: $font-md;
color: $text-muted;
}
/* 通知详情弹窗 */
.modal-mask {
@include modal-mask;
}
.detail-modal {
width: 100%;
max-height: 80vh;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
.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; }
}
</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,32 +51,107 @@
<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>
<!-- 管理员入口 -->
<view v-if="userStore.userInfo?.role === 'admin'" class="menu-card mt">
<view class="menu-item admin-entry" @tap="goAdmin">
<Icon name="settings" :size="32" color="#FF8C69" />
<text class="mi-label">管理后台</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<!-- 关于弹窗 -->
<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>
@@ -118,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'
@@ -135,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)
@@ -143,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
@@ -170,11 +277,26 @@ onShow(async () => {
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
groupStore.fetchGroups()
groupStore.fetchGroups(),
userStore.fetchUserInfo()
])
} 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' })
}
@@ -183,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' })
}
@@ -206,6 +340,10 @@ function goGroupManage() {
uni.navigateTo({ url: '/pages/group-manage/index' })
}
function goAdmin() {
uni.navigateTo({ url: '/pages/admin/index' })
}
function promptCreateGroup() {
// #ifdef MP-WEIXIN
// 小程序不支持 prompt使用输入框页面
@@ -276,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) {
@@ -296,88 +446,106 @@ async function exportData() {
return
}
function csvEscape(val: string): string {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"'
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, '""') + '"'
}
return val
}
return val
const header = '日期,类型,分类,金额(元),备注\n'
const rows = allList.map(t => {
const amount = (t.amount / 100).toFixed(2)
const type = t.type === 'expense' ? '支出' : '收入'
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
}).join('\n')
const csv = '' + header + rows
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.csv`
saveFile(csv, fileName, 'text/csv')
}
const header = '日期,类型,分类,金额(元),备注\n'
const rows = allList.map(t => {
const amount = (t.amount / 100).toFixed(2)
const type = t.type === 'expense' ? '支出' : '收入'
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
}).join('\n')
const csv = '' + header + rows
// #ifdef H5
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `小菜记账_${getLocalDateStr()}.csv`
a.click()
URL.revokeObjectURL(url)
uni.hideLoading()
uni.showToast({ title: '导出成功', icon: 'success' })
// #endif
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${getLocalDateStr()}.csv`
fs.writeFileSync(filePath, csv, 'utf-8')
uni.hideLoading()
uni.openDocument({
filePath,
fileType: 'csv',
success: () => {},
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
})
// #endif
} catch {
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([content], { type: `${mimeType};charset=utf-8;` })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
uni.hideLoading()
uni.showToast({ title: '导出成功', icon: 'success' })
// #endif
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`
fs.writeFileSync(filePath, content, 'utf-8')
uni.hideLoading()
const ext = mimeType === 'application/json' ? 'json' : 'csv'
uni.openDocument({
filePath,
fileType: ext,
success: () => {},
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
})
// #endif
}
</script>
<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; }
}
@@ -394,168 +562,160 @@ 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) {
.p-skeleton {
.qs-skeleton {
animation: none;
}
}
.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: $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;
}
}
@@ -566,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 {
@@ -586,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;
}
@@ -602,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 {
@@ -638,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

@@ -94,7 +94,7 @@
:height="420"
/>
<view class="trend-list">
<view v-for="(point, i) in trendData" :key="i" class="trend-item">
<view v-for="point in trendData" :key="point.date" class="trend-item">
<text class="trend-date">{{ point.date.slice(8) }}</text>
<view class="trend-bar-bg">
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
@@ -119,8 +119,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
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'
@@ -131,12 +134,12 @@ import Skeleton from '@/components/Skeleton/Skeleton.vue'
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')
const overview = computed(() => statsStore.overview)
const categoryStats = computed(() => statsStore.categoryStats)
const trendData = computed(() => statsStore.trendData)
const { overview, categoryStats, trendData } = storeToRefs(statsStore)
const loading = ref(true)
const loadError = ref(false)
const maxSingle = ref(0)
@@ -234,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
@@ -260,13 +264,29 @@ 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)
const startDate = `${currentMonth.value}-01`
const lastDay = new Date(y, m, 0).getDate()
const endDate = `${currentMonth.value}-${String(lastDay).padStart(2, '0')}`
const data = await getTransactions({ page: 1, pageSize: 1, type: tabType.value, startDate, endDate, sortBy: 'amount' })
const data = await getTransactions({ page: 1, pageSize: 1, type: tabType.value, startDate, endDate, sortBy: 'amount', group_id: groupStore.currentGroupId })
maxSingle.value = data.list?.[0]?.amount || 0
} catch {
maxSingle.value = 0
@@ -277,7 +297,7 @@ async function fetchPrevMonthData() {
try {
const [y, m] = currentMonth.value.split('-').map(Number)
const prevMonth = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}`
const data = await getOverview({ month: prevMonth })
const data = await getOverview({ month: prevMonth, group_id: groupStore.currentGroupId })
// 确保数据字段是数字类型,避免 null/undefined 导致 Infinity
prevMonthData.value = {
expense: Number(data?.expense) || 0,
@@ -294,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')}`
@@ -312,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; }
@@ -360,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 {
@@ -406,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 {
@@ -478,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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 836 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 B

After

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 B

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 B

After

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 713 B

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 878 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 B

After

Width:  |  Height:  |  Size: 459 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 B

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 B

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 719 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 B

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 B

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 B

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 640 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 B

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 557 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 B

After

Width:  |  Height:  |  Size: 1.3 KiB

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

@@ -0,0 +1,25 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '@/api/filter'
import type { FilterParams } from '@/api/filter'
export const useFilterStore = defineStore('filter', () => {
const savedFilters = ref<api.SavedFilter[]>([])
async function fetchSavedFilters() {
savedFilters.value = await api.getSavedFilters()
}
async function saveFilter(name: string, filters: FilterParams) {
const result = await api.saveFilter({ name, filters })
await fetchSavedFilters()
return result.id
}
async function deleteFilter(id: number) {
await api.deleteFilter(id)
savedFilters.value = savedFilters.value.filter(f => f.id !== id)
}
return { savedFilters, fetchSavedFilters, saveFilter, deleteFilter }
})

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 })
}
/** 获取群组列表 */
@@ -50,19 +50,19 @@ export const useGroupStore = defineStore('group', () => {
loading.value = true
try {
groups.value = await api.getGroups()
// 仅在成功获取后验证 currentGroupId 是否仍有效
if (currentGroupId.value !== null) {
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
if (!stillValid) {
await switchToPersonal()
}
}
} catch {
groups.value = []
// 网络失败时保留上次数据,不丢失群组选择
} finally {
loading.value = false
}
// 验证 currentGroupId 是否仍有效(群组可能已被解散或退出)
if (currentGroupId.value !== null) {
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
if (!stillValid) {
switchToPersonal()
}
}
}
/** 创建群组 */
@@ -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

@@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '@/api/notification'
import type { Notification } from '@/api/notification'
export const useNotificationStore = defineStore('notification', () => {
const unreadCount = ref(0)
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

@@ -19,9 +19,9 @@ export const useTransactionStore = defineStore('transaction', () => {
})
transactions.value = data.list
total.value = data.total
return data
} catch (err) {
transactions.value = []
total.value = 0
// 不清空数据,保留上次成功的状态,避免并发请求失败时丢失已有数据
throw err
} finally {
loading.value = false

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 ''
@@ -24,13 +28,21 @@ export const useUserStore = defineStore('user', () => {
if (userInfo.value) {
uni.setStorageSync('xc:nickname', userInfo.value.nickname)
uni.setStorageSync('xc:avatar_url', userInfo.value.avatar_url)
uni.setStorageSync('xc:role', userInfo.value.role)
}
}
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) {
@@ -40,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,13 +8,6 @@ interface RequestOptions {
data?: any
}
interface LoginResult {
token: string
userId: number
nickname: string
avatar_url: string
}
let isRedirectingToLogin = false
/** 重登录期间挂起的请求,登录成功后统一重试 */
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = []
@@ -23,6 +18,7 @@ export function saveLoginResult(data: LoginResult) {
uni.setStorageSync('xc:userId', data.userId)
uni.setStorageSync('xc:nickname', data.nickname)
uni.setStorageSync('xc:avatar_url', data.avatar_url)
if (data.role) uni.setStorageSync('xc:role', data.role)
}
/** 执行重登录 */
@@ -51,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}` } : {})
@@ -61,19 +58,28 @@ 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 })
if (!isRedirectingToLogin) {
isRedirectingToLogin = true
const retries = [...pendingRetries]
pendingRetries = []
reLogin()
.then(() => retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject)))
.then(() => {
// 处理快照时已入队的 + reLogin 期间新入队的请求
const retries = [...pendingRetries]
pendingRetries = []
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
})
.catch(() => {
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
const retries = [...pendingRetries]
pendingRetries = []
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
})
.finally(() => { isRedirectingToLogin = false })
@@ -84,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' })
@@ -92,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 文件
- 不做无关重构
- 不做数据库结构大改(如需迁移需单独评审)

119
scripts/generate-icons.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* 从 Lucide SVG 图标生成高质量 PNG
* 用法: node scripts/generate-icons.js
*/
const sharp = require('sharp')
const fs = require('fs')
const path = require('path')
const ICONS_DIR = path.join(__dirname, '../client/src/static/icons')
const LUCIDE_DIR = path.join(__dirname, '../node_modules/lucide-static/icons')
// 颜色映射hex -> RGB
const COLORS = {
'#2D1B1B': { r: 45, g: 27, b: 27 }, // 暖深棕
'#8B7E7E': { r: 139, g: 126, b: 126 }, // 灰色
'#BFB3B3': { r: 191, g: 179, b: 179 }, // 浅灰
'#E0D6D0': { r: 224, g: 214, b: 208 }, // 极浅灰
'#FF8C69': { r: 255, g: 140, b: 105 }, // 珊瑚橙
'#FF6B6B': { r: 255, g: 107, b: 107 }, // 红色
'#7BC67E': { r: 123, g: 198, b: 126 }, // 绿色
'#FFFFFF': { r: 255, g: 255, b: 255 }, // 白色
}
// 图标配置:{ lucideName, variants: { colorHex: outputName } }
const ICONS = [
// 导航
{ lucide: 'arrow-left', variants: { '#2D1B1B': 'arrow-left-dark', '#8B7E7E': 'arrow-left-gray' } },
{ lucide: 'arrow-right', variants: { '#8B7E7E': 'arrow-right-gray', '#E0D6D0': 'arrow-right-pale' } },
{ lucide: 'chevron-right', variants: { '#BFB3B3': 'chevron-right-light' } },
// 操作
{ lucide: 'plus', variants: { '#FFFFFF': 'plus-white' } },
{ lucide: 'check', variants: { '#FFFFFF': 'check-white' } },
{ lucide: 'edit', variants: { '#8B7E7E': 'edit-gray', '#BFB3B3': 'edit-light' } },
{ lucide: 'trash-2', variants: { '#FF8C69': 'trash-orange', '#FF6B6B': 'trash-red', '#FFFFFF': 'trash-white' } },
{ lucide: 'refresh-cw', variants: { '#8B7E7E': 'refresh-gray', '#FF8C69': 'refresh-orange' } },
// 功能
{ lucide: 'calendar', variants: { '#8B7E7E': 'calendar-gray' } },
{ lucide: 'bell', variants: { '#8B7E7E': 'bell-gray' } },
{ lucide: 'user', variants: { '#8B7E7E': 'user-gray', '#FFFFFF': 'user-white' } },
{ lucide: 'alert-circle', variants: { '#BFB3B3': 'alert-circle-light' } },
// 趋势
{ lucide: 'trending-down', variants: { '#FF6B6B': 'trend-down-red' } },
{ lucide: 'trending-up', variants: { '#7BC67E': 'trend-up-green' } },
]
// TabBar 图标(需要 normal + active 两套)
const TABBAR_ICONS = [
{ lucide: 'home', normal: 'home', active: 'home-active', normalColor: '#BFB3B3', activeColor: '#FF8C69' },
{ lucide: 'bar-chart-2', normal: 'chart', active: 'chart-active', normalColor: '#BFB3B3', activeColor: '#FF8C69' },
{ lucide: 'receipt', normal: 'bill', active: 'bill-active', normalColor: '#BFB3B3', activeColor: '#FF8C69' },
{ lucide: 'user', normal: 'user', active: 'user-active', normalColor: '#BFB3B3', activeColor: '#FF8C69' },
]
const SIZE = 96 // 输出 PNG 尺寸px
async function generateIcon(svgName, outputPath, color) {
const svgPath = path.join(LUCIDE_DIR, `${svgName}.svg`)
if (!fs.existsSync(svgPath)) {
console.warn(`⚠ SVG not found: ${svgName}.svg`)
return false
}
let svgContent = fs.readFileSync(svgPath, 'utf-8')
// 替换 SVG 中的 stroke 颜色
const { r, g, b } = COLORS[color]
svgContent = svgContent.replace(/stroke="[^"]*"/g, `stroke="rgb(${r},${g},${b})"`)
// 也处理可能的 fill
svgContent = svgContent.replace(/fill="none"/g, 'fill="none"')
// 设置输出尺寸
svgContent = svgContent.replace(/width="[^"]*"/, `width="${SIZE}"`)
svgContent = svgContent.replace(/height="[^"]*"/, `height="${SIZE}"`)
await sharp(Buffer.from(svgContent))
.resize(SIZE, SIZE)
.png()
.toFile(outputPath)
return true
}
async function main() {
console.log('🎨 开始生成图标...\n')
let total = 0
let success = 0
// 生成普通图标
for (const icon of ICONS) {
for (const [color, outputName] of Object.entries(icon.variants)) {
const outputPath = path.join(ICONS_DIR, `${outputName}.png`)
total++
if (await generateIcon(icon.lucide, outputPath, color)) {
console.log(`${outputName}.png`)
success++
}
}
}
// 生成 TabBar 图标
for (const icon of TABBAR_ICONS) {
for (const [outputName, color] of [[icon.normal, icon.normalColor], [icon.active, icon.activeColor]]) {
const outputPath = path.join(ICONS_DIR, `${outputName}.png`)
total++
if (await generateIcon(icon.lucide, outputPath, color)) {
console.log(`${outputName}.png (tabbar)`)
success++
}
}
}
console.log(`\n✅ 完成: ${success}/${total} 个图标已生成`)
}
main().catch(console.error)

View File

@@ -11,6 +11,14 @@ async function columnExists(conn: mysql.Connection, table: string, column: strin
return (rows as any[])[0].cnt > 0
}
async function tableExists(conn: mysql.Connection, table: string): Promise<boolean> {
const [rows] = await conn.query(
`SELECT COUNT(*) as cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
[table]
)
return (rows as any[])[0].cnt > 0
}
async function runMigrations(conn: mysql.Connection) {
console.log('[DB] Checking migrations...')
@@ -83,6 +91,192 @@ async function runMigrations(conn: mysql.Connection) {
await conn.query("ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date)")
console.log('[DB] Groups migration complete')
}
// users 表添加 role管理员角色
const hasRole = await columnExists(conn, 'users', 'role')
if (!hasRole) {
console.log('[DB] Migrating: adding role to users')
await conn.query("ALTER TABLE users ADD COLUMN role ENUM('user', 'admin') DEFAULT 'user' AFTER avatar_url")
}
// 通知表
const hasNotifications = await tableExists(conn, 'notifications')
if (!hasNotifications) {
console.log('[DB] Migrating: creating notifications table')
await conn.query(`
CREATE TABLE IF NOT EXISTS notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT DEFAULT NULL COMMENT 'NULL=系统公告非NULL=个人通知',
group_id INT DEFAULT NULL COMMENT '群组通知关联',
type ENUM('system', 'group', 'personal') NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT,
is_read TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_read (user_id, is_read),
INDEX idx_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// 保存的筛选方案表
const hasSavedFilters = await tableExists(conn, 'saved_filters')
if (!hasSavedFilters) {
console.log('[DB] Migrating: creating saved_filters table')
await conn.query(`
CREATE TABLE IF NOT EXISTS saved_filters (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(50) NOT NULL,
filters JSON NOT NULL COMMENT '筛选条件',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// 公告系统增强:扩展 notifications 表
const hasPinned = await columnExists(conn, 'notifications', 'is_pinned')
if (!hasPinned) {
console.log('[DB] Migrating: extending notifications table')
await conn.query('ALTER TABLE notifications ADD COLUMN is_pinned TINYINT(1) DEFAULT 0 AFTER is_read')
await conn.query('ALTER TABLE notifications ADD COLUMN is_urgent TINYINT(1) DEFAULT 0 AFTER is_pinned')
await conn.query('ALTER TABLE notifications ADD COLUMN publish_at TIMESTAMP NULL AFTER is_urgent')
await conn.query('ALTER TABLE notifications ADD COLUMN expire_at TIMESTAMP NULL AFTER publish_at')
await conn.query('ALTER TABLE notifications ADD COLUMN image_url VARCHAR(500) DEFAULT \'\' AFTER expire_at')
await conn.query('ALTER TABLE notifications ADD COLUMN link_url VARCHAR(500) DEFAULT \'\' AFTER image_url')
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,8 @@ 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
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -30,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),
@@ -72,3 +75,98 @@ CREATE TABLE IF NOT EXISTS group_members (
FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT DEFAULT NULL COMMENT 'NULL=系统公告非NULL=个人通知',
group_id INT DEFAULT NULL COMMENT '群组通知关联',
type ENUM('system', 'group', 'personal') NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT,
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=立即发布)',
expire_at TIMESTAMP NULL COMMENT '过期时间NULL=永不过期)',
image_url VARCHAR(500) DEFAULT '' COMMENT '公告配图',
link_url VARCHAR(500) DEFAULT '' COMMENT '公告链接',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_read (user_id, is_read),
INDEX idx_type (type),
INDEX idx_pinned (is_pinned, created_at),
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,
name VARCHAR(50) NOT NULL,
filters JSON NOT NULL COMMENT '筛选条件:{type, categories[], startDate, endDate, minAmount, maxAmount, keyword}',
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'
@@ -15,6 +16,14 @@ import categoryRoutes from './routes/category'
import backupRoutes from './routes/backup'
import userRoutes from './routes/user'
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
@@ -43,6 +52,9 @@ app.use(cors({
}))
app.use(express.json({ limit: '10kb' }))
// 请求日志
app.use(requestLogger)
// 限流:登录接口 — 每分钟最多 10 次
const authLimiter = rateLimit({
windowMs: 60 * 1000,
@@ -97,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)
@@ -107,6 +155,14 @@ app.use('/api/categories', apiLimiter, categoryRoutes)
app.use('/api/backup', apiLimiter, backupRoutes)
app.use('/api/user', apiLimiter, userRoutes)
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 {
@@ -117,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: '服务器错误' })
}
}

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