Compare commits

...

65 Commits

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

- add stats dashboard aggregation and batched tracking

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

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

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

埋点页:
- 事件类型显示中文名称(页面访问、用户操作等)
- 今日事件和热门事件使用标签样式
- 添加日期筛选和关键词搜索
- 事件类型筛选可横向滚动
2026-06-08 16:12:38 +08:00
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
wangxiaogang
1e975cd828 style: 样式微调 2026-06-04 22:25:09 +08:00
161 changed files with 27463 additions and 1657 deletions

10
.githooks/pre-commit Normal file
View File

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

4
.gitignore vendored
View File

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

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记录回到个人视图
- 身份切换: 「我的」页底部弹窗选择,全局生效

42
CODEBUDDY.md Normal file
View File

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

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

1209
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

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

View File

@@ -0,0 +1,51 @@
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 interface MyFeedback {
id: number
type: string
content: string
contact: string
status: 'pending' | 'processed' | 'ignored'
admin_reply?: string
created_at: string
}
/** 获取我的反馈列表 */
export function getMyFeedbacks(params?: { page?: number; pageSize?: number }) {
return request<{ list: MyFeedback[]; total: number; page: number; pageSize: number }>({ url: '/feedback/mine', data: params })
}
/** 更新反馈状态(管理员) */
export function updateFeedbackStatus(id: number, status: 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' })
}

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

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

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

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

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

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

View File

@@ -15,6 +15,14 @@ export interface Transaction {
group_id?: number | null
creator_nickname?: string
creator_avatar?: string
tags?: TagItem[]
}
/** 标签简略信息 */
export interface TagItem {
id: number
name: string
color: string
}
/** 交易列表响应 */
@@ -23,6 +31,8 @@ export interface TransactionList {
total: number
page: number
pageSize: number
filteredExpense?: number
filteredIncome?: number
}
/** 获取交易列表参数 */
@@ -34,6 +44,11 @@ export interface TransactionParams {
endDate?: string
sortBy?: string
group_id?: number | null
category_id?: number
minAmount?: number
maxAmount?: number
keyword?: string
tagId?: number
}
/** 获取交易列表 */
@@ -54,6 +69,7 @@ export function createTransaction(data: {
note?: string
date: string
group_id?: number | null
tagIds?: number[]
}) {
return request<{ id: number }>({ url: '/transactions', method: 'POST', data })
}
@@ -65,6 +81,7 @@ export function updateTransaction(id: number, data: {
category_id?: number
note?: string
date: string
tagIds?: number[]
}) {
return request({ url: `/transactions/${id}`, method: 'PUT', data })
}
@@ -73,3 +90,16 @@ export function updateTransaction(id: number, data: {
export function deleteTransaction(id: number) {
return request({ url: `/transactions/${id}`, method: 'DELETE' })
}
/** 导入交易记录 */
export function importTransactions(data: {
transactions: Array<{
date: string
type: 'expense' | 'income'
category_name: string
amount: number
note?: string
}>
}) {
return request<{ imported: number; skipped: number }>({ url: '/transactions/import', method: 'POST', data })
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 }
const result = applyAmountKey(
amountStr.value,
effectiveCursorIndex.value,
key,
{ max: props.max }
)
if (result.accepted) {
emit('update:modelValue', result.value)
emit('update:cursorIndex', result.cursorIndex)
}
let next: string
if (!current || current === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = current + key
}
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() {
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()
const current = amountStr.value
if (current.length > 1) {
emitValue(current.slice(0, -1))
emit('update:modelValue', result.value)
emit('update:cursorIndex', result.cursorIndex)
deleteTimer = setTimeout(doDeleteRepeat, 120)
} else {
emitValue('')
deleteLongPressed = false
deleteTimer = null
}
}
function onDeleteTouchStart() {
if (deleteTimer !== null) clearTimeout(deleteTimer)
deleteLongPressed = false
deleteTimer = setTimeout(doDeleteRepeat, 400)
}
function onDeleteTouchEnd() {
if (deleteTimer !== null) {
clearTimeout(deleteTimer)
deleteTimer = null
}
}
onBeforeUnmount(() => {
if (deleteTimer !== null) {
clearTimeout(deleteTimer)
deleteTimer = null
}
})
function onConfirm() {
vibrate()
emit('confirm')
@@ -90,40 +141,91 @@ function onConfirm() {
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.numpad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
padding: 0 32rpx;
gap: $space-sm;
padding: $space-sm $space-xl;
// 非 fixed 模式的显示/隐藏
&:not(.numpad-fixed) {
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s ease;
overflow: hidden;
max-height: 600rpx;
&.numpad-hidden {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
}
}
// fixed 模式的滑入/滑出
&.numpad-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: $bg;
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.numpad-hidden {
transform: translateY(100%);
}
}
}
.key {
height: 96rpx;
border-radius: 24rpx;
background: #FFF8F0;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-xl;
background: $surface;
border: 2rpx solid $border;
box-shadow: $shadow-clay;
@include flex-center;
transition: all $transition-fast;
-webkit-tap-highlight-color: transparent;
user-select: none;
&:active { background: #F0E0D6; }
&:active {
background: $surface-warm;
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
}
.key-text {
font-family: 'Fredoka', sans-serif;
font-size: 44rpx;
font-weight: 500;
color: #2D1B1B;
color: $text;
}
.key.fn {
background: #FFE8E0;
&:active { background: #FFD0C0; }
background: $primary-light;
border-color: darken($primary-light, 8%);
&:active {
background: darken($primary-light, 8%);
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,33 @@
<template>
<view class="save-success" v-if="visible" @tap="hide">
<view class="success-icon" :class="{ show: animating }">
<view class="save-success" v-if="visible">
<!-- 纸屑粒子 -->
<view class="confetti-container" v-if="animating">
<view
v-for="i in 20"
:key="i"
class="confetti"
:class="`confetti-${i}`"
:style="confettiStyle(i)"
></view>
</view>
<view class="success-content" :class="{ show: animating }">
<view class="success-icon-wrap">
<view class="success-ring"></view>
<view class="success-icon">
<Icon name="check" :size="80" color="#FFFFFF" />
</view>
<text class="success-text" :class="{ show: animating }">{{ text }}</text>
</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>
</view>
</template>
@@ -14,53 +38,115 @@ 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)
// 纸屑样式:随机位置、颜色、动画延迟
const confettiColors = ['#FF8C69', '#FFD166', '#7BC67E', '#6C9BCF', '#FF69B4', '#FFB89A']
function confettiStyle(i: number) {
const angle = (i / 20) * 360
const distance = 120 + Math.random() * 200
const color = confettiColors[i % confettiColors.length]
const delay = Math.random() * 0.3
const size = 8 + Math.random() * 12
const isRect = i % 3 === 0
return {
'--tx': `${Math.cos(angle * Math.PI / 180) * distance}rpx`,
'--ty': `${Math.sin(angle * Math.PI / 180) * distance - 100}rpx`,
'--rot': `${Math.random() * 720 - 360}deg`,
background: color,
width: isRect ? `${size}rpx` : `${size * 0.6}rpx`,
height: `${size}rpx`,
borderRadius: isRect ? '2rpx' : '50%',
animationDelay: `${delay}s`,
}
}
watch(() => props.visible, (val) => {
if (val) {
setTimeout(() => { animating.value = true }, 50)
setTimeout(() => { hide() }, 2000)
// 如果不显示继续按钮,自动关闭
if (!props.showContinue) {
setTimeout(() => { onBack() }, 2200)
}
} 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);
display: flex;
align-items: center;
justify-content: center;
transform: scale(0);
// 纸屑容器
.confetti-container {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
z-index: 1;
}
.confetti {
position: absolute;
opacity: 0;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
animation: confetti-burst 0.8s cubic-bezier(0.23, 1, 0.32, 1) forwards;
}
@keyframes confetti-burst {
0% {
transform: translate(0, 0) rotate(0deg) scale(0);
opacity: 0;
}
20% {
opacity: 1;
}
100% {
transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(1);
opacity: 0;
}
}
.success-content {
display: flex;
flex-direction: column;
align-items: center;
transform: scale(0.8);
opacity: 0;
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
z-index: 2;
&.show {
transform: scale(1);
@@ -68,18 +154,84 @@ 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-wrap {
position: relative;
@include flex-center;
}
&.show {
transform: translateY(0);
.success-ring {
position: absolute;
width: 200rpx;
height: 200rpx;
border-radius: 50%;
border: 4rpx solid rgba(123, 198, 126, 0.3);
animation: ring-pulse 1s ease-out 0.3s both;
}
@keyframes ring-pulse {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
.success-icon {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: linear-gradient(135deg, $success, darken($success, 10%));
@include flex-center;
box-shadow: 0 8rpx 32rpx rgba(123, 198, 126, 0.4);
}
.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;
transition: transform $transition-fast, opacity $transition-fast;
&.continue {
background: $primary;
&:active { opacity: 0.8; transform: scale(0.96); }
}
&.back {
background: rgba(255, 255, 255, 0.2);
&:active { background: rgba(255, 255, 255, 0.3); transform: scale(0.96); }
}
}
.action-text {
font-size: $font-lg;
font-weight: 600;
color: $surface;
}
@media (prefers-reduced-motion: reduce) {
.confetti { animation: none !important; display: none; }
.success-ring { animation: none !important; display: none; }
.success-content { transition: opacity 0.2s ease; }
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

@@ -12,7 +12,8 @@
"path": "pages/add/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "记一笔"
"navigationBarTitleText": "记一笔",
"enablePullDownRefresh": false
}
},
{
@@ -35,35 +36,149 @@
"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
}
},
{
"path": "pages/profile-edit/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "编辑资料"
"navigationBarTitleText": "编辑资料",
"enablePullDownRefresh": false
}
},
{
"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": "意见反馈",
"enablePullDownRefresh": false
}
},
{
"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
}
},
{
"path": "pages/tag-manage/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "标签管理",
"enablePullDownRefresh": true
}
},
{
"path": "pages/backup-manage/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "数据备份",
"enablePullDownRefresh": true
}
},
{
"path": "pages/data-import/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "数据导入"
}
}
],
@@ -72,7 +187,8 @@
"navigationBarTitleText": "小菜记账",
"navigationBarBackgroundColor": "#FFF8F0",
"backgroundColor": "#FFF8F0",
"backgroundTextStyle": "dark"
"backgroundTextStyle": "dark",
"enablePullDownRefresh": true
},
"tabBar": {
"color": "#BFB3B3",

View File

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

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 () => {
try { await waitForReady() } catch { /* 超时,继续加载 */ }
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,609 @@
<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 { getAvatarUrl } from '@/utils/avatar'
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 () => {
try { await waitForReady() } catch { /* 超时,继续加载 */ }
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 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,400 @@
<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="health-card" v-if="health">
<view class="health-header">
<text class="health-title">系统状态</text>
<view class="health-badge" :class="health.status">
<view class="health-dot"></view>
<text class="health-badge-text">{{ health.status === 'ok' ? '正常' : health.status === 'degraded' ? '降级' : '异常' }}</text>
</view>
</view>
<view class="detail-row">
<text class="detail-label">数据库</text>
<text class="detail-value" :class="health.db === 'connected' ? 'income' : 'expense'">{{ health.db === 'connected' ? '已连接' : '断开' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">运行时间</text>
<text class="detail-value">{{ formatUptime(health.uptime) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">版本</text>
<text class="detail-value">{{ health.version }}</text>
</view>
</view>
<!-- 功能入口 -->
<view class="section-title">管理功能</view>
<view class="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 { getHealth } from '@/api/health'
import type { HealthStatus } from '@/api/health'
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)
const health = ref<HealthStatus | null>(null)
onMounted(async () => {
try { await waitForReady() } catch { /* 超时,继续加载 */ }
try {
dashboard.value = await getDashboard()
} catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
// 加载健康状态(不阻塞页面)
try {
health.value = await getHealth()
} catch {
// 健康检查失败不影响主流程
}
})
// 静默刷新
async function refreshDashboard() {
try {
dashboard.value = await getDashboard()
health.value = await getHealth()
} 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' }) }
function formatUptime(seconds: number): string {
if (!seconds) return '-'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}${hours}小时`
if (hours > 0) return `${hours}小时${mins}`
return `${mins}分钟`
}
</script>
<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;
}
/* 健康状态卡片 */
.health-card {
background: $surface;
border-radius: $radius-xl;
padding: $space-lg;
border: 2rpx solid $border;
box-shadow: $shadow-md;
margin-bottom: $space-lg;
}
.health-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $space-md;
}
.health-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
}
.health-badge {
display: flex;
align-items: center;
gap: $space-xs;
padding: 4rpx 16rpx;
border-radius: $radius-lg;
&.ok { background: rgba(123, 198, 126, 0.1); }
&.degraded { background: rgba(255, 215, 0, 0.1); }
&.error { background: rgba(255, 107, 107, 0.1); }
}
.health-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
.ok & { background: $success; }
.degraded & { background: #FFD700; }
.error & { background: $danger; }
}
.health-badge-text {
font-size: $font-sm;
font-weight: 500;
.ok & { color: $success; }
.degraded & { color: #CC9900; }
.error & { color: $danger; }
}
</style>

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

View File

@@ -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,42 @@
<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">
<!-- 标签筛选条 -->
<scroll-view class="tag-filter-scroll" scroll-x v-if="tagStore.tags.length > 0">
<view class="tag-filter-row">
<view class="tag-filter-chip" :class="{ active: filterTagId === null }" @tap="filterTagId = null; loadTx(true)">
<text class="tag-filter-text">全部标签</text>
</view>
<view
v-for="tag in tagStore.tags"
:key="tag.id"
class="tag-filter-chip"
:class="{ active: filterTagId === tag.id }"
@tap="filterTagId = tag.id; loadTx(true)"
>
<view class="tag-filter-dot" :style="{ background: tag.color }"></view>
<text class="tag-filter-text">{{ tag.name }}</text>
</view>
</view>
</scroll-view>
<!-- 筛选结果统计 -->
<view class="filter-stats" v-if="hasAdvancedFilter && !loading">
<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>
@@ -29,13 +62,15 @@
:hideDate="true"
:showCreator="groupStore.isGroupMode"
:currentUserId="userStore.userInfo?.id"
:disableSwipe="groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id"
:swipeHint="!swipeHintDismissed && isFirstDeletable(item)"
@item-tap="onEdit(item)"
@delete="onDelete(item)"
/>
</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 +87,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,36 +100,96 @@
<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>
<Snackbar
:visible="snackbarVisible"
:message="snackbarMsg"
@undo="onUndoDelete"
/>
</view>
</template>
<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 { useTagStore } from '@/stores/tag'
import { useTransactionStore } from '@/stores/transaction'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions } 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 Snackbar from '@/components/Snackbar/Snackbar.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 tagStore = useTagStore()
const txStore = useTransactionStore()
const loading = ref(false)
const filterType = ref('all')
const filterTagId = ref<number | null>(null)
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 // 请求序号,防止并发覆盖
// Snackbar 删除撤销相关
const snackbarVisible = ref(false)
const snackbarMsg = ref('')
let snackbarTimer: number | null = null
// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复
const swipeHintDismissed = ref(false)
const HINT_KEY = 'xiaocai_swipe_hint_shown'
function isFirstDeletable(item: Transaction): boolean {
if (swipeHintDismissed.value) return false
// 群组模式下只有自己的记录可删除
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) return false
// 找到第一个可删除的项
for (const group of groupedTx.value) {
for (const tx of group.items) {
if (groupStore.isGroupMode && tx.user_id !== userStore.userInfo?.id) continue
return tx.id === item.id
}
}
return false
}
// 动画播完后标记已显示
try {
if (uni.getStorageSync(HINT_KEY)) {
swipeHintDismissed.value = true
}
} catch {}
setTimeout(() => {
swipeHintDismissed.value = true
try { uni.setStorageSync(HINT_KEY, '1') } catch {}
}, 3000)
// 高级筛选
const showFilter = ref(false)
const 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(() => {
@@ -116,7 +211,14 @@ const groupedTx = computed(() => {
const initialLoaded = ref(false)
onMounted(async () => {
try {
await waitForReady()
} catch {
// waitForReady 超时:显示网络异常提示
loadError.value = true
return
}
tagStore.fetchTags().catch(() => {})
loadTx(true).finally(() => { initialLoaded.value = true })
})
@@ -126,6 +228,7 @@ onShow(() => {
})
function switchFilter(type: string) {
if (loading.value) return // 防止重复提交
filterType.value = type
loadTx(true)
}
@@ -136,29 +239,49 @@ 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
if (filterTagId.value) params.tagId = filterTagId.value
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' })
uni.showToast({ title: '能编辑他人的记录', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
@@ -169,27 +292,48 @@ async function onDelete(item: any) {
uni.showToast({ title: '只能删除自己的记录', icon: 'none' })
return
}
uni.showModal({
title: '删除记录',
content: `确定删除这笔 ${item.type === 'expense' ? '支出' : '收入'} ¥${(item.amount / 100).toFixed(2)}`,
success: async (res) => {
if (res.confirm) {
try {
await txStore.deleteTransaction(item.id)
// 乐观删除:先从列表移除,显示 Snackbar 供撤销
const itemIndex = txList.value.findIndex(t => t.id === item.id)
const started = txStore.startDelete(item.id, item, itemIndex)
if (!started) {
uni.showToast({ title: '删除失败,请刷新后重试', icon: 'none' })
return
}
txList.value = txList.value.filter(t => t.id !== item.id)
total.value--
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
total.value = Math.max(0, total.value - 1)
// 更新筛选金额
if (item.type === 'expense') {
filteredExpense.value = Math.max(0, filteredExpense.value - item.amount)
} else {
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
}
// 显示 Snackbar清理旧定时器避免连续删除时旧定时器误隐藏新提示
snackbarMsg.value = '已删除 1 条记录'
snackbarVisible.value = true
if (snackbarTimer) clearTimeout(snackbarTimer)
snackbarTimer = setTimeout(async () => {
snackbarVisible.value = false
snackbarTimer = null
const ok = await txStore.confirmDelete()
if (!ok) loadTx(true, true)
}, 3000) as unknown as number
}
/** 撤销删除 */
function onUndoDelete() {
txStore.cancelDelete()
snackbarVisible.value = false
if (snackbarTimer) {
clearTimeout(snackbarTimer)
snackbarTimer = null
}
}
})
// 重新加载以恢复完整列表
loadTx(true)
}
// 触底加载更多
onReachBottom(() => {
if (noMore.value || txStore.loading) return
if (noMore.value || loading.value) return
page.value++
loadTx(false)
})
@@ -201,124 +345,176 @@ 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;
}
/* 标签筛选条 */
.tag-filter-scroll {
white-space: nowrap;
padding: 0 $space-xl $space-sm;
}
.tag-filter-row {
display: inline-flex;
gap: $space-xs;
}
.tag-filter-chip {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: $space-xs $space-md;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
flex-shrink: 0;
transition: all $transition-normal;
&.active {
background: $primary-light;
border-color: $primary;
}
&:active { opacity: 0.7; }
}
.tag-filter-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
}
.tag-filter-text {
font-size: $font-sm;
color: $text-sec;
.active & {
color: $primary;
font-weight: 500;
}
}
</style>

View File

@@ -60,42 +60,44 @@
<!-- 自定义金额 -->
<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>
<!-- Numpad -->
<Numpad v-model="amountStr" @confirm="onConfirm" />
<AmountEditor
ref="amountEditorRef"
v-model="amountStr"
:fixed="true"
size="large"
unit="元"
:auto-focus="false"
@confirm="onConfirm"
/>
</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 presets = [1000, 2000, 3000, 5000, 10000]
@@ -116,19 +118,22 @@ function syncBudgetData() {
/** 获取个人支出(不传 group_id始终只算自己的消费 */
async function fetchMyExpense() {
try {
const data = await getOverview({ month: currentMonth, group_id: null })
monthExpense.value = Number(data?.expense) || 0
} catch {
monthExpense.value = 0
}
}
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
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 +146,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 +192,44 @@ 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 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
$numpad-h: 464rpx;
padding-bottom: calc(#{$numpad-h} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$numpad-h} + env(safe-area-inset-bottom));
}
.header-fixed {
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 +237,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 +257,126 @@ 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 {
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 32rpx;
padding: 24rpx 32rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
}
.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;
}
}
.input-unit {
font-size: 28rpx;
color: #8B7E7E;
}
.input-cursor {
font-family: 'Fredoka', monospace;
font-size: 56rpx;
font-weight: 300;
color: #FF8C69;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.preview-skeleton {
animation: none;
}
.cursor {
animation: none;
opacity: 1;
}
}
</style>

View File

@@ -19,7 +19,10 @@
</view>
<view class="cat-list">
<view v-for="cat in currentCategories" :key="cat.id" class="cat-row">
<view v-for="cat in currentCategories" :key="cat.id" class="cat-row" :data-id="cat.id">
<view class="cat-drag-handle" @touchstart="onDragStart" @touchmove="onDragMove" @touchend="onDragEnd" v-if="tabType === 'expense' || tabType === 'income'">
<Icon name="dragHandle" :size="24" color="#BFB3B3" />
</view>
<view class="cat-info" @tap="cat.is_custom && onEdit(cat)">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" />
<text class="cat-name">{{ cat.name }}</text>
@@ -42,46 +45,22 @@
</view>
<!-- 添加弹窗 -->
<view class="modal-mask" v-if="showAddModal" @tap="showAddModal = false">
<view class="modal" @tap.stop>
<text class="modal-title">添加自定义分类</text>
<input class="modal-input" v-model="newName" placeholder="分类名称" maxlength="10" />
<view class="color-picker">
<view
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ selected: selectedColor === c }"
:style="{ background: c }"
@tap="selectedColor = c"
<EditModal
v-model:visible="showAddModal"
title="添加自定义分类"
:fields="catFields"
:modelValue="addModel"
@confirm="onAdd"
/>
</view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showAddModal = false">取消</view>
<view class="modal-btn confirm" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
</view>
</view>
</view>
<!-- 编辑弹窗 -->
<view class="modal-mask" v-if="showEditModal" @tap="showEditModal = false">
<view class="modal" @tap.stop>
<text class="modal-title">编辑分类</text>
<input class="modal-input" v-model="editName" placeholder="分类名称" maxlength="10" />
<view class="color-picker">
<view
v-for="c in colorOptions" :key="c"
class="color-dot"
:class="{ selected: editColor === c }"
:style="{ background: c }"
@tap="editColor = c"
<EditModal
v-model:visible="showEditModal"
title="编辑分类"
:fields="catFields"
:modelValue="editModel"
@confirm="onEditConfirm"
/>
</view>
<view class="modal-btns">
<view class="modal-btn cancel" @tap="showEditModal = false">取消</view>
<view class="modal-btn confirm" @tap="onEditConfirm">确定</view>
</view>
</view>
</view>
<!-- 迁移弹窗 -->
<view class="modal-mask" v-if="showMigrateModal" @tap="showMigrateModal = false">
@@ -111,26 +90,37 @@
<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'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
import EditModal from '@/components/EditModal/EditModal.vue'
import type { EditField } from '@/components/EditModal/EditModal.vue'
import { statusBarHeight, capsuleRight } from '@/utils/system'
const catStore = useCategoryStore()
const tabType = ref<'expense' | 'income'>('expense')
const showAddModal = ref(false)
const newName = ref('')
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
const selectedColor = ref(colorOptions[0])
const showEditModal = ref(false)
/** 分类添加/编辑弹窗字段配置 */
const catFields: EditField[] = [
{ key: 'name', label: '分类名称', type: 'text', placeholder: '分类名称', maxlength: 10 },
{ key: 'color', label: '颜色', type: 'color' },
]
const addModel = ref({ name: '', color: '#FF8C69' })
const editModel = ref({ name: '', color: '#FF8C69' })
// 拖拽排序相关
let dragStartY = 0
let dragCurrentIndex = -1
let dragItemEl: any = null
// 编辑相关
const showEditModal = ref(false)
const editingCat = ref<Category | null>(null)
const editName = ref('')
const editColor = ref('')
// 迁移相关
const showMigrateModal = ref(false)
@@ -148,32 +138,37 @@ const migrateTargets = computed(() => {
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await catStore.fetchCategories()
initialLoaded.value = true
})
// 返回页面时静默刷新
// 返回页面时静默刷新(使用缓存)
onShow(() => {
if (initialLoaded.value) catStore.fetchCategories()
if (initialLoaded.value) catStore.fetchCategories() // 不强制刷新(使用缓存)
})
onPullDownRefresh(async () => {
await catStore.fetchCategories(true) // 强制刷新
uni.stopPullDownRefresh()
})
function goBack() {
uni.navigateBack()
}
async function onAdd() {
const name = newName.value.trim()
async function onAdd(data: Record<string, any>) {
const name = (data.name || '').toString().trim()
if (!name) return
try {
await catStore.addCategory({
name,
icon: name[0],
color: selectedColor.value,
color: data.color,
type: tabType.value
})
newName.value = ''
addModel.value = { name: '', color: '#FF8C69' }
showAddModal.value = false
uni.showToast({ title: '添加成功', icon: 'success' })
} catch {
@@ -183,21 +178,20 @@ async function onAdd() {
function onEdit(cat: Category) {
editingCat.value = cat
editName.value = cat.name
editColor.value = cat.color
editModel.value = { name: cat.name, color: cat.color }
showEditModal.value = true
}
async function onEditConfirm() {
async function onEditConfirm(data: Record<string, any>) {
if (!editingCat.value) return
const name = editName.value.trim()
const name = (data.name || '').toString().trim()
if (!name) {
uni.showToast({ title: '请输入名称', icon: 'none' })
return
}
try {
await catStore.updateCategory(editingCat.value.id, { name, color: editColor.value })
await catStore.updateCategory(editingCat.value.id, { name, color: data.color })
showEditModal.value = false
uni.showToast({ title: '修改成功', icon: 'success' })
} catch {
@@ -265,103 +259,152 @@ async function onMigrateConfirm() {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
/** 拖拽排序:开始 */
function onDragStart(e: any) {
dragStartY = e.touches[0].clientY
dragItemEl = e.currentTarget?.parentElement
if (dragItemEl) {
const id = Number(dragItemEl.dataset?.id)
const idx = currentCategories.value.findIndex(c => c.id === id)
dragCurrentIndex = idx
dragItemEl.style?.opacity && (dragItemEl.style.opacity = '0.6')
}
}
/** 拖拽排序:移动 */
function onDragMove(e: any) {
// 视觉反馈由 CSS transition 处理,这里仅用于阻止页面滚动
}
/** 拖拽排序:结束 */
function onDragEnd(e: any) {
if (dragItemEl) {
dragItemEl.style?.opacity && (dragItemEl.style.opacity = '1')
}
const endY = e.changedTouches[0].clientY
const delta = endY - dragStartY
const threshold = 50
if (Math.abs(delta) > threshold && dragCurrentIndex >= 0) {
const direction = delta > 0 ? 1 : -1
const newIndex = dragCurrentIndex + direction
const cats = [...currentCategories.value]
if (newIndex >= 0 && newIndex < cats.length) {
// 交换位置
const temp = cats[dragCurrentIndex]
cats[dragCurrentIndex] = cats[newIndex]
cats[newIndex] = temp
// 保存排序
const sortedIds = cats.map(c => c.id)
catStore.sortCategories(sortedIds).catch(() => {})
}
}
dragCurrentIndex = -1
dragItemEl = null
}
</script>
<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; }
}
.cat-drag-handle {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&:active { opacity: 0.6; }
}
.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 +415,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;
@@ -392,27 +435,7 @@ async function onMigrateConfirm() {
&:active { transform: scale(0.95); }
}
.color-picker {
display: flex;
gap: 20rpx;
margin-bottom: 24rpx;
flex-wrap: wrap;
}
.color-dot {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid transparent;
transition: all 0.2s;
&.selected {
border-color: #2D1B1B;
transform: scale(1.15);
}
&:active { opacity: 0.7; }
}
/* 迁移弹窗样式 */
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
@@ -425,87 +448,76 @@ 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;
}
.modal-input {
width: 100%;
height: 96rpx;
background: #FFF8F0;
border-radius: 24rpx;
padding: 0 32rpx;
font-size: 28rpx;
margin-bottom: 24rpx;
box-sizing: border-box;
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,343 @@
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="nav-title">数据导入</text>
<view class="nav-placeholder"></view>
</view>
</view>
<view class="content">
<view class="section">
<text class="section-title">选择文件</text>
<text class="section-desc">支持 CSVJSON 格式文件需小于 5MB</text>
<view class="file-picker" @tap="onPickFile">
<Icon name="upload" :size="48" color="#FF8C69" />
<text class="file-picker-text">{{ fileName || '点击选择文件' }}</text>
</view>
</view>
<view class="section" v-if="fileName">
<text class="section-title">文件预览</text>
<text class="preview-info">格式{{ fileFormat?.toUpperCase() }} · 大小{{ fileSize }}</text>
<text class="preview-info">识别到 {{ parsedCount }} 条记录</text>
</view>
<view class="section" v-if="importResult">
<text class="section-title">导入结果</text>
<view class="result-card">
<view class="result-row">
<text class="result-label">成功导入</text>
<text class="result-value success">{{ importResult.imported }} </text>
</view>
<view class="result-row">
<text class="result-label">跳过</text>
<text class="result-value warn">{{ importResult.skipped }} </text>
</view>
</view>
</view>
<view class="action-area" v-if="fileName && !importResult">
<view class="import-btn" :class="{ disabled: importing || parsedCount === 0 }" @tap="onImport">
<text class="import-text">{{ importing ? '导入中...' : '开始导入' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { statusBarHeight } from '@/utils/system'
import { importTransactions } from '@/api/transaction'
import Icon from '@/components/Icon/Icon.vue'
const fileName = ref('')
const fileSize = ref('')
const fileFormat = ref<'csv' | 'json' | null>(null)
const parsedCount = ref(0)
const parsedData = ref<Array<{
date: string
type: 'expense' | 'income'
category_name: string
amount: number
note?: string
}>>([])
const importing = ref(false)
const importResult = ref<{ imported: number; skipped: number } | null>(null)
function goBack() {
uni.navigateBack()
}
function onPickFile() {
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.accept = '.csv,.json'
input.onchange = (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
handleFile(file)
}
input.click()
// #endif
// #ifdef MP-WEIXIN
uni.chooseMessageFile({
count: 1,
type: 'file',
extension: ['.csv', '.json'],
success: (res) => {
const file = res.tempFiles[0]
fileName.value = file.name
fileSize.value = formatFileSize(file.size)
fileFormat.value = file.name.endsWith('.json') ? 'json' : 'csv'
// 读取文件内容
const fs = uni.getFileSystemManager()
try {
const content = fs.readFileSync(file.path, 'utf-8') as string
parseContent(content, fileFormat.value!)
} catch {
uni.showToast({ title: '文件读取失败', icon: 'none' })
}
}
})
// #endif
}
function handleFile(file: File) {
fileName.value = file.name
fileSize.value = formatFileSize(file.size)
fileFormat.value = file.name.endsWith('.json') ? 'json' : 'csv'
// 检查大小
if (file.size > 5 * 1024 * 1024) {
uni.showToast({ title: '文件不能超过 5MB', icon: 'none' })
return
}
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result as string
parseContent(content, fileFormat.value!)
}
reader.readAsText(file, 'utf-8')
}
function parseContent(content: string, format: 'csv' | 'json') {
try {
importResult.value = null
if (format === 'json') {
const items = JSON.parse(content)
if (!Array.isArray(items)) {
uni.showToast({ title: 'JSON 需为数组格式', icon: 'none' })
return
}
parsedData.value = items.map((item: any) => ({
date: item.date || item.日期 || '',
type: (item.type === '收入' || item.type === 'income') ? 'income' as const : 'expense' as const,
category_name: item.category || item.category_name || item.分类 || '未分类',
amount: Math.round((Number(item.amount || item.金额 || 0)) * 100),
note: item.note || item.备注 || ''
})).filter((item: any) => item.date && item.amount > 0)
} else {
// CSV 解析
const lines = content.replace(/^\uFEFF/, '').split('\n').filter(line => line.trim())
if (lines.length < 2) {
uni.showToast({ title: 'CSV 文件为空', icon: 'none' })
return
}
// 跳过表头
parsedData.value = lines.slice(1).map(line => {
const parts = parseCsvLine(line)
return {
date: (parts[0] || '').trim(),
type: (parts[1] || '').includes('收入') ? 'income' as const : 'expense' as const,
category_name: (parts[2] || '未分类').trim(),
amount: Math.round(parseFloat((parts[3] || '0').replace(/,/g, '')) * 100),
note: (parts[4] || '').trim()
}
}).filter(item => item.date && item.amount > 0)
}
parsedCount.value = parsedData.value.length
} catch {
uni.showToast({ title: '文件解析失败', icon: 'none' })
parsedData.value = []
parsedCount.value = 0
}
}
function parseCsvLine(line: string): string[] {
const result: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"'
i++
} else {
inQuotes = !inQuotes
}
} else if (char === ',' && !inQuotes) {
result.push(current)
current = ''
} else {
current += char
}
}
result.push(current)
return result
}
async function onImport() {
if (importing.value || parsedCount.value === 0) return
importing.value = true
try {
const result = await importTransactions({ transactions: parsedData.value })
importResult.value = result
uni.showToast({ title: '导入完成', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e.message || '导入失败', icon: 'none' })
} finally {
importing.value = false
}
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(1)} ${units[i]}`
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
@include page-base;
}
.header-fixed { @include sticky-header; }
.status-bar { @include status-bar; }
.nav-bar {
display: flex;
align-items: center;
padding: $space-sm $space-lg;
}
.nav-back {
@include nav-back;
border-radius: 50%;
&:active { background: $border; }
}
.nav-title {
flex: 1;
text-align: center;
font-size: $font-2xl;
font-weight: 600;
color: $text;
}
.nav-placeholder { width: 72rpx; }
.content {
padding: $space-xl;
}
.section {
margin-bottom: $space-2xl;
}
.section-title {
font-size: $font-lg;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-xs;
}
.section-desc {
font-size: $font-md;
color: $text-muted;
display: block;
margin-bottom: $space-md;
}
.file-picker {
@include card($radius-2xl);
padding: $space-2xl;
display: flex;
flex-direction: column;
align-items: center;
gap: $space-md;
&:active { background: $surface-warm; }
}
.file-picker-text {
font-size: $font-lg;
color: $text-sec;
}
.preview-info {
font-size: $font-md;
color: $text-sec;
display: block;
margin-bottom: $space-xs;
}
.result-card {
@include card($radius-xl);
padding: $space-lg;
}
.result-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-sm 0;
border-bottom: 2rpx solid $border;
&:last-child { border-bottom: none; }
}
.result-label { font-size: $font-lg; color: $text-sec; }
.result-value {
font-size: $font-lg;
font-weight: 600;
&.success { color: $success; }
&.warn { color: $primary; }
}
.action-area {
padding-top: $space-md;
}
.import-btn {
@include btn-primary;
width: 100%;
&.disabled { opacity: 0.5; pointer-events: none; }
}
.import-text { @include btn-primary-text; }
</style>

View File

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

View File

@@ -45,13 +45,22 @@
<text class="section-title">我的群组</text>
</view>
<view v-if="groupStore.loading" class="loading-box">
<text class="loading-text">加载中...</text>
<view v-if="groupStore.loading" class="skeleton-list">
<view v-for="i in 2" :key="i" class="skeleton-card">
<Skeleton width="80rpx" height="80rpx" variant="circle" />
<view class="skeleton-info">
<Skeleton width="200rpx" height="28rpx" variant="text" />
<Skeleton width="280rpx" height="24rpx" variant="text" />
</view>
</view>
</view>
<view v-else-if="groupStore.groups.length === 0" class="empty-box">
<view class="empty-icon-wrap">
<Icon name="user" :size="48" color="#BFB3B3" />
</view>
<text class="empty-text">还没有加入任何群组</text>
<text class="empty-hint">创建或加入一个群组和家人朋友一起记账</text>
</view>
<view v-else class="group-list">
@@ -62,11 +71,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 +84,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 +133,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,21 +149,25 @@
<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'
import { getAvatarUrl } from '@/utils/avatar'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.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)
@@ -159,7 +176,7 @@ const members = ref<GroupMember[]>([])
const currentGroup = ref<Group | null>(null)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
await groupStore.fetchGroups()
initialLoaded.value = true
})
@@ -169,6 +186,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()
}
@@ -180,13 +220,6 @@ function copyInviteCode(code: string) {
})
}
/** 获取头像 URL */
function getAvatarUrl(avatarUrl: string): string {
if (!avatarUrl) return ''
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
return `${API_BASE}/user/avatar/${avatarUrl}`
}
async function handleCreate() {
if (saving.value) return
const name = newGroupName.value.trim()
@@ -326,59 +359,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,106 +411,131 @@ 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 {
// 骨架屏
.skeleton-list { padding: 0 $space-xl; }
.skeleton-card {
display: flex; align-items: center; gap: $space-md;
padding: $space-lg; margin-bottom: $space-sm;
background: $surface; border-radius: $radius-xl;
border: 2rpx solid $border;
}
.skeleton-info {
flex: 1; display: flex; flex-direction: column; gap: $space-xs;
}
// 空状态
.empty-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
gap: $space-sm;
padding: 80rpx 0;
}
.loading-text, .empty-text {
font-size: 28rpx;
color: #BFB3B3;
.empty-icon-wrap {
width: 120rpx; height: 120rpx; border-radius: 50%;
background: $surface-warm; @include flex-center;
margin-bottom: $space-sm;
}
.empty-text {
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.empty-hint {
font-size: $font-md;
color: $text-muted;
}
.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 +555,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 +582,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 +603,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 +697,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 +717,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 +736,7 @@ function handleDissolve(group: Group) {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #F0E0D6;
background: $border;
flex-shrink: 0;
}
@@ -693,34 +746,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>
@@ -105,10 +108,40 @@
<text class="state-text">加载失败下拉刷新重试</text>
</view>
<view class="state-box" v-if="!loading && !loadError && recentTx.length === 0">
<!-- 网络异常全屏提示 -->
<view class="network-error" v-if="networkError">
<Icon name="alert-circle" :size="64" color="#BFB3B3" />
<text class="network-error-title">网络连接异常</text>
<text class="network-error-desc">请检查网络后点击重试</text>
<view class="network-error-btn" @tap="retryNetwork">
<text class="network-error-btn-text">重新加载</text>
</view>
</view>
<view class="state-box" v-if="!loading && !loadError && !networkError && recentTx.length === 0">
<Icon name="edit" :size="48" color="#BFB3B3" />
<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 +153,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,14 +171,16 @@ 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 networkError = ref(false)
const todayExpense = ref(0)
const todayIncome = ref(0)
const todayCount = ref(0)
@@ -155,21 +194,63 @@ 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) {
try {
await waitForReady()
} catch {
// waitForReady 超时:显示网络异常提示
if (!silent) {
networkError.value = true
loading.value = false
}
return
}
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 +259,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,13 +292,13 @@ 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) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
uni.showToast({ title: '能编辑自己的记录', icon: 'none' })
uni.showToast({ title: '能编辑他人的记录', icon: 'none' })
return
}
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
@@ -224,41 +307,42 @@ function onTxTap(item: any) {
function goBills() {
uni.switchTab({ url: '/pages/bills/index' })
}
/** 网络异常重试 */
function retryNetwork() {
networkError.value = false
loadData()
}
</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 +353,64 @@ 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;
@include fade-in-up;
}
.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 +418,268 @@ function goBills() {
font-family: 'Fredoka', sans-serif;
font-size: 72rpx;
font-weight: 600;
color: #2D1B1B;
color: $text;
font-variant-numeric: tabular-nums;
@include count-up;
}
.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;
@include fade-in-up(0.1s);
}
.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;
@include fade-in-up(0.15s);
}
.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;
transition: transform $transition-fast, box-shadow $transition-fast;
&:active {
transform: scale(0.97);
box-shadow: inset 2rpx 2rpx 6rpx rgba(45, 27, 27, 0.1);
transform: scale(0.94);
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 {
@include state-box;
}
.state-text {
@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;
}
/* 网络异常全屏提示 */
.network-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 80rpx 0;
padding: 120rpx $space-xl;
gap: $space-md;
}
.state-text {
font-size: 28rpx;
color: #BFB3B3;
.network-error-title {
font-size: $font-2xl;
font-weight: 600;
color: $text;
margin-top: $space-lg;
}
.network-error-desc {
font-size: $font-lg;
color: $text-sec;
}
.network-error-btn {
margin-top: $space-lg;
padding: $space-md $space-2xl;
background: $primary;
border-radius: $radius-xl;
&:active { opacity: 0.8; }
}
.network-error-btn-text {
font-size: $font-lg;
font-weight: 600;
color: $surface;
}
</style>

View File

@@ -0,0 +1,554 @@
<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="skeleton-list">
<view v-for="i in 4" :key="i" class="skeleton-notif">
<Skeleton width="64rpx" height="64rpx" variant="circle" />
<view class="skeleton-info">
<Skeleton width="240rpx" height="28rpx" variant="text" />
<Skeleton width="400rpx" height="24rpx" variant="text" />
<Skeleton width="120rpx" height="20rpx" variant="text" />
</view>
</view>
</view>
<view v-else-if="list.length === 0" class="empty-box">
<view class="empty-icon-wrap">
<Icon name="bell" :size="48" color="#BFB3B3" />
</view>
<text class="empty-text">暂无通知</text>
<text class="empty-hint">新的通知会在这里显示</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 Skeleton from '@/components/Skeleton/Skeleton.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 () => {
try { await waitForReady() } catch { /* 超时,继续加载 */ }
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;
}
}
// 骨架屏
.skeleton-list { padding: 0 $space-xl; }
.skeleton-notif {
display: flex; align-items: flex-start; gap: $space-lg;
padding: $space-lg; margin-bottom: $space-sm;
background: $surface; border-radius: $radius-xl;
border: 2rpx solid $border;
}
.skeleton-info {
flex: 1; display: flex; flex-direction: column; gap: $space-xs;
}
// 空状态
.empty-box {
display: flex;
flex-direction: column;
align-items: center;
gap: $space-sm;
padding: 120rpx 0;
}
.empty-icon-wrap {
width: 120rpx; height: 120rpx; border-radius: 50%;
background: $surface-warm; @include flex-center;
margin-bottom: $space-sm;
}
.empty-text {
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.empty-hint {
font-size: $font-md;
color: $text-muted;
}
.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,13 +54,15 @@ 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)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
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,129 @@
<text class="mi-label">分类管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="exportData">
<view class="menu-item" @tap="goTagManage">
<text class="mi-label">标签管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goRecurring">
<text class="mi-label">周期账单</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="openExportPanel">
<text class="mi-label">数据导出</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goDataImport">
<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 class="menu-item" @tap="goBackupManage">
<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-section">
<text class="export-label">导出方式</text>
<view class="export-tabs">
<view class="export-tab" :class="{ active: exportMode === 'local' }" @tap="exportMode = 'local'">本地导出</view>
<view class="export-tab" :class="{ active: exportMode === 'server' }" @tap="exportMode = 'server'">服务端导出</view>
</view>
<text class="export-hint" v-if="exportMode === 'server'">服务端导出支持大数据量直接从服务器下载文件</text>
</view>
<!-- 预览 & 导出 -->
<view class="export-footer">
<text class="export-preview"> <text class="export-count">{{ exportCount }}</text> 条记录</text>
<view class="export-btn" :class="{ disabled: exportCount === 0 || exportLoading }" @tap="doExport">
<text class="export-btn-text">{{ exportLoading ? '导出中...' : '导出 ' + exportFormat.toUpperCase() }}</text>
</view>
</view>
</view>
</view>
<!-- 身份选择弹窗 -->
<view class="modal-mask" v-if="showIdentity" @tap="showIdentity = false">
<view class="identity-modal" @tap.stop>
@@ -118,14 +215,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 +234,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,8 +243,38 @@ 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 exportMode = ref<'local' | 'server'>('local')
const exportCount = ref(0)
const exportLoading = ref(false)
function openExportPanel() {
showExportPanel.value = true
updateExportCount()
}
async function updateExportCount() {
try {
const data = await getTransactions({
page: 1, pageSize: 1,
startDate: exportStartDate.value,
endDate: exportEndDate.value,
...(exportType.value !== 'all' ? { type: exportType.value } : {}),
group_id: groupStore.currentGroupId
})
exportCount.value = data.total
} catch {
exportCount.value = 0
}
}
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
loading.value = true
try {
await Promise.all([
@@ -170,11 +300,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 +328,30 @@ function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' })
}
function goTagManage() {
uni.navigateTo({ url: '/pages/tag-manage/index' })
}
function goRecurring() {
uni.navigateTo({ url: '/pages/recurring/index' })
}
function goPrivacy() {
uni.navigateTo({ url: '/pages/privacy/index' })
}
function goFeedback() {
uni.navigateTo({ url: '/pages/feedback/index' })
}
function goDataImport() {
uni.navigateTo({ url: '/pages/data-import/index' })
}
function goBackupManage() {
uni.navigateTo({ url: '/pages/backup-manage/index' })
}
function goProfileEdit() {
uni.navigateTo({ url: '/pages/profile-edit/index' })
}
@@ -206,6 +375,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 +449,73 @@ 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
// 服务端导出
if (exportMode.value === 'server') {
exportLoading.value = true
try {
// 分页获取全部数据(服务端 pageSize 上限 100
const allList: any[] = []
const { getExportUrl } = await import('@/api/export')
const url = await getExportUrl({
startDate: exportStartDate.value,
endDate: exportEndDate.value,
type: exportType.value,
format: exportFormat.value
})
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifdef MP-WEIXIN
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode === 200) {
const ext = exportFormat.value === 'json' ? 'json' : 'csv'
uni.openDocument({
filePath: res.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => uni.showToast({ title: '打开失败', icon: 'none' })
})
}
},
fail: () => uni.showToast({ title: '下载失败', icon: 'none' })
})
// #endif
uni.showToast({ title: '导出成功', icon: 'success' })
} catch {
uni.showToast({ title: '导出失败', icon: 'none' })
} finally {
exportLoading.value = false
showExportPanel.value = false
}
return
}
// 本地导出
exportLoading.value = true
uni.showLoading({ title: '导出中 0%' })
try {
// 分页获取筛选后的数据
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,6 +524,18 @@ async function exportData() {
return
}
if (exportFormat.value === 'json') {
const json = JSON.stringify(allList.map(t => ({
date: t.date,
type: t.type === 'expense' ? '支出' : '收入',
category: t.category_name || '未分类',
amount: (t.amount / 100).toFixed(2),
note: t.note || ''
})), null, 2)
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.json`
saveFile(json, fileName, 'application/json')
} else {
function csvEscape(val: string): string {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"'
@@ -310,13 +550,25 @@ async function exportData() {
}).join('\n')
const csv = '' + header + rows
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.csv`
saveFile(csv, fileName, 'text/csv')
}
} catch {
uni.hideLoading()
uni.showToast({ title: '导出失败', icon: 'none' })
} finally {
exportLoading.value = false
showExportPanel.value = false
}
}
function saveFile(content: string, fileName: string, mimeType: string) {
// #ifdef H5
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const blob = new Blob([content], { type: `${mimeType};charset=utf-8;` })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `小菜记账_${getLocalDateStr()}.csv`
a.download = fileName
a.click()
URL.revokeObjectURL(url)
uni.hideLoading()
@@ -325,59 +577,53 @@ async function exportData() {
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${getLocalDateStr()}.csv`
fs.writeFileSync(filePath, csv, 'utf-8')
const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`
fs.writeFileSync(filePath, content, 'utf-8')
uni.hideLoading()
const ext = mimeType === 'application/json' ? 'json' : 'csv'
uni.openDocument({
filePath,
fileType: 'csv',
fileType: ext,
success: () => {},
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
})
// #endif
} catch {
uni.hideLoading()
uni.showToast({ title: '导出失败', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
@include page-base;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
@include sticky-header;
}
.status-bar {
background: #FFF8F0;
@include status-bar;
}
.page-title {
display: block;
text-align: center;
font-size: 36rpx;
font-size: $font-2xl;
font-weight: 600;
color: #2D1B1B;
padding: 16rpx 0 24rpx;
color: $text;
padding: $space-sm 0 $space-md;
}
.profile-card {
margin: 0 40rpx;
padding: 48rpx;
background: linear-gradient(135deg, #FF8C69, #FFB89A);
border-radius: 40rpx;
margin: 0 $space-xl;
padding: $space-2xl;
background: linear-gradient(135deg, $primary, #FFB89A);
border-radius: $radius-2xl;
box-shadow: 8rpx 8rpx 24rpx rgba(45, 27, 27, 0.1);
display: flex;
align-items: center;
gap: 32rpx;
gap: $space-lg;
&:active { opacity: 0.85; }
}
@@ -394,168 +640,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 +804,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 +824,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 +840,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 +876,178 @@ 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;
}
.export-hint {
font-size: $font-sm;
color: $text-muted;
display: block;
margin-top: $space-sm;
}
</style>

View File

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

View File

@@ -22,6 +22,14 @@
</view>
</view>
<view class="period-wrap">
<view class="period-tabs">
<view class="period-tab" :class="{ active: period === 'week' }" @tap="switchPeriod('week')"></view>
<view class="period-tab" :class="{ active: period === 'month' }" @tap="switchPeriod('month')"></view>
<view class="period-tab" :class="{ active: period === 'year' }" @tap="switchPeriod('year')"></view>
</view>
</view>
<view class="overview-card">
<view class="ov-item">
<text class="ov-label">{{ tabType === 'expense' ? '总支出' : '总收入' }}</text>
@@ -94,8 +102,8 @@
:height="420"
/>
<view class="trend-list">
<view v-for="(point, i) in trendData" :key="i" class="trend-item">
<text class="trend-date">{{ point.date.slice(8) }}</text>
<view v-for="point in trendData" :key="point.date" class="trend-item">
<text class="trend-date">{{ point.label || (point.date.slice(8) + '日') }}</text>
<view class="trend-bar-bg">
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
</view>
@@ -119,8 +127,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 +142,13 @@ 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 period = ref<'week' | 'month' | 'year'>('month')
const { overview, categoryStats, trendData } = storeToRefs(statsStore)
const loading = ref(true)
const loadError = ref(false)
const maxSingle = ref(0)
@@ -173,7 +185,7 @@ const categoryChartData = computed(() => {
const trendChartData = computed(() => {
if (trendData.value.length === 0) return null
return {
categories: trendData.value.map(p => p.date.slice(8) + '日'),
categories: trendData.value.map(p => p.label || (p.date.slice(8) + '日')),
series: [{
name: tabType.value === 'expense' ? '支出' : '收入',
data: trendData.value.map(p => p.amount / 100)
@@ -226,7 +238,7 @@ const monthCompareText = computed(() => {
const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
try { await waitForReady() } catch { /* 超时,继续加载 */ }
loadData().finally(() => { initialLoaded.value = true })
})
@@ -234,19 +246,21 @@ 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())
// 周期切换时重新加载数据
watch(period, () => loadData())
async function loadData(silent = false) {
const seq = ++loadSeq
try {
if (!silent) loading.value = true
loadError.value = false
// 使用聚合接口一次获取 overview + category + trend再并行请求额外数据
await Promise.all([
statsStore.fetchOverview(currentMonth.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value),
statsStore.fetchDashboard(currentMonth.value, tabType.value, period.value),
fetchMaxSingle(),
fetchPrevMonthData()
])
@@ -260,13 +274,26 @@ async function loadData(silent = false) {
}
}
// tab 切换时只加载分类和趋势数据(不重复请求上月对比和最大单笔)
async function loadTabData() {
const seq = ++loadSeq
try {
await statsStore.fetchDashboard(currentMonth.value, tabType.value, period.value)
await 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 +304,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 +321,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')}`
@@ -305,6 +346,10 @@ function nextMonth() {
currentMonth.value = m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`
}
function switchPeriod(p: 'week' | 'month' | 'year') {
period.value = p
}
function getBarWidth(amount: number) {
const max = Math.max(...trendData.value.map(t => t.amount), 1)
return (amount / max) * 100
@@ -312,46 +357,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 +395,64 @@ 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;
border-radius: $radius-lg;
box-shadow: $shadow-md;
}
}
.period-wrap {
display: flex;
justify-content: center;
margin-bottom: $space-lg;
}
.period-tabs {
display: inline-flex;
background: $surface-warm;
border-radius: $radius-lg;
padding: $space-xs;
gap: $space-xs;
}
.period-tab {
padding: $space-xs $space-lg;
border-radius: $radius-lg;
font-size: $font-md;
color: $text-sec;
transition: all $transition-normal;
&.active {
background: $surface;
color: $primary;
font-weight: 600;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
box-shadow: $shadow-sm;
}
}
.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 +463,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,76 +532,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 {
width: 52rpx;
font-size: 24rpx;
color: #8B7E7E;
text-align: right;
font-size: $font-md;
color: $text-sec;
}
.trend-bar-bg {
flex: 1;
height: 12rpx;
border-radius: 6rpx;
background: #F0E0D6;
background: $border;
overflow: hidden;
}
.trend-bar {
height: 100%;
border-radius: 6rpx;
background: linear-gradient(90deg, #FF8C69, #FFB89A);
background: linear-gradient(90deg, $primary, #FFB89A);
transition: width 0.6s ease-out;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
.trend-amount {
width: 120rpx;
font-family: 'Fredoka', sans-serif;
font-size: 24rpx;
font-size: $font-md;
font-weight: 500;
color: #2D1B1B;
color: $text;
}
</style>

View File

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

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

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

View File

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

View File

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

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