Compare commits
61 Commits
a35689cdda
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 218c23687d | |||
| b33ebf0879 | |||
| 5a0ae0b28d | |||
| d170df5c51 | |||
| 263a7c4616 | |||
| 61f9b33f8c | |||
| ae9b415822 | |||
| 31f6487d61 | |||
| 5df910c9f1 | |||
| 9f5802d634 | |||
| f586f9e117 | |||
| 6b26951b1b | |||
| 768c72d7b7 | |||
| ac20a70a20 | |||
| 4e4b7dac69 | |||
| df6f1a54d6 | |||
| 7bf899f0b1 | |||
| a277f1533b | |||
| f745f4b4f0 | |||
| 6908ffed0e | |||
| 057c69800e | |||
| 986c187156 | |||
| 91ff95ea48 | |||
| a1e62e487d | |||
| be665f7859 | |||
| 4e43a9049a | |||
| 618cd20ebf | |||
| 3279440ce6 | |||
| 2b04f057a9 | |||
| 41cddcd2c1 | |||
| 4d3a390075 | |||
| ef4ab774b0 | |||
| af15a41186 | |||
| 6ef44805f5 | |||
| 50c41d8759 | |||
| b21c90c560 | |||
| 7f6da1fa10 | |||
| 3ce5ff4b65 | |||
| fc3491742f | |||
| a1e3106899 | |||
| 162f2dc89c | |||
| 860c599b7c | |||
| dcab68b3a8 | |||
| cc09a177ef | |||
| 0de4c9832c | |||
| 2f122d0a96 | |||
| d681dc67a3 | |||
| 9423f15f29 | |||
| 65c2a81486 | |||
| 24166aeb62 | |||
| ecfb67872d | |||
| 8b5589192d | |||
| 5124b390a7 | |||
| 6af876a8f4 | |||
| 62ca400b64 | |||
| 2705335002 | |||
| 29614fdb70 | |||
| 8cf240f76b | |||
|
|
1abc55323d | ||
|
|
bd7af8e512 | ||
|
|
4f42f2c342 |
10
.githooks/pre-commit
Normal file
10
.githooks/pre-commit
Normal 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
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
38
CLAUDE.md
38
CLAUDE.md
@@ -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
42
CODEBUDDY.md
Normal 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
77
DEV.md
@@ -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
1209
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,24 +2,51 @@
|
||||
import { onLaunch } from '@dcloudio/uni-app'
|
||||
import { wxLogin, demoLogin } from '@/api/auth'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { saveLoginResult } from '@/utils/request'
|
||||
import { markReady } from '@/utils/app-ready'
|
||||
import { setupErrorTracking, trackAction, trackError } from '@/utils/tracker'
|
||||
|
||||
/** 登录重试(最多3次) */
|
||||
async function loginWithRetry(loginFn: () => Promise<any>, retries = 3): Promise<boolean> {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const data = await loginFn()
|
||||
saveLoginResult(data)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error(`[Auth] Login attempt ${i + 1} failed:`, e)
|
||||
trackError(e as Error, { context: 'login', attempt: i + 1 })
|
||||
if (i < retries - 1) {
|
||||
// 等待1秒后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
onLaunch(() => {
|
||||
// 初始化错误追踪
|
||||
setupErrorTracking()
|
||||
trackAction('app_launch')
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const configStore = useConfigStore()
|
||||
groupStore.init()
|
||||
configStore.fetchConfig()
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: async (loginRes) => {
|
||||
if (loginRes.code) {
|
||||
try {
|
||||
saveLoginResult(await wxLogin(loginRes.code))
|
||||
const success = await loginWithRetry(() => wxLogin(loginRes.code))
|
||||
if (success) {
|
||||
groupStore.fetchGroups()
|
||||
console.log('[Auth] Login success')
|
||||
} catch (e) {
|
||||
console.error('[Auth] Login failed:', e)
|
||||
} else {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none', duration: 3000 })
|
||||
}
|
||||
}
|
||||
markReady()
|
||||
@@ -30,13 +57,15 @@ onLaunch(() => {
|
||||
// #ifdef H5
|
||||
const token = uni.getStorageSync('xc:token')
|
||||
if (!token) {
|
||||
demoLogin().then((data) => {
|
||||
saveLoginResult(data)
|
||||
groupStore.fetchGroups()
|
||||
console.log('[Auth] H5 demo login success')
|
||||
}).catch((e) => {
|
||||
console.error('[Auth] H5 demo login failed:', e)
|
||||
}).finally(() => { markReady() })
|
||||
loginWithRetry(demoLogin).then((success) => {
|
||||
if (success) {
|
||||
groupStore.fetchGroups()
|
||||
console.log('[Auth] H5 demo login success')
|
||||
} else {
|
||||
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none', duration: 3000 })
|
||||
}
|
||||
markReady()
|
||||
})
|
||||
} else {
|
||||
groupStore.fetchGroups()
|
||||
markReady()
|
||||
@@ -49,10 +78,15 @@ onLaunch(() => {
|
||||
<style lang="scss">
|
||||
@import './uni.scss';
|
||||
|
||||
// 全局盒模型
|
||||
view, text, image, input, textarea, scroll-view, picker {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
page {
|
||||
background-color: #FFF8F0;
|
||||
background-color: $bg;
|
||||
font-family: 'Nunito', 'PingFang SC', sans-serif;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface Dashboard {
|
||||
growthPercent: number
|
||||
lastMonthTxCount: number
|
||||
lastMonthTxAmount: number
|
||||
monthlyExpense?: number
|
||||
monthlyIncome?: number
|
||||
lastMonthExpense?: number
|
||||
lastMonthIncome?: number
|
||||
}
|
||||
|
||||
/** 用户信息(管理员视角) */
|
||||
@@ -46,7 +50,19 @@ export function updateUserRole(id: number, role: 'user' | 'admin') {
|
||||
return request({ url: `/admin/users/${id}/status`, method: 'PUT', data: { role } })
|
||||
}
|
||||
|
||||
/** 删除用户 */
|
||||
export function deleteUser(id: number) {
|
||||
return request({ url: `/admin/users/${id}`, method: 'DELETE' })
|
||||
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' })
|
||||
}
|
||||
|
||||
@@ -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
38
client/src/api/backup.ts
Normal 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)}`
|
||||
}
|
||||
@@ -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
11
client/src/api/config.ts
Normal 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
26
client/src/api/export.ts
Normal 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()}`
|
||||
}
|
||||
51
client/src/api/feedback.ts
Normal file
51
client/src/api/feedback.ts
Normal 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 } })
|
||||
}
|
||||
15
client/src/api/health.ts
Normal file
15
client/src/api/health.ts
Normal 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
91
client/src/api/logs.ts
Normal 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 })
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { request } from '@/utils/request'
|
||||
import { API_BASE } from '@/config'
|
||||
|
||||
/** 通知 */
|
||||
export interface Notification {
|
||||
@@ -64,6 +65,42 @@ export function markAllRead() {
|
||||
return request({ url: '/notifications/read-all', method: 'PUT' })
|
||||
}
|
||||
|
||||
/** 上传通知图片(返回文件名) */
|
||||
export function uploadNotificationImage(filePath: string): Promise<{ image_url: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('xc:token')
|
||||
uni.uploadFile({
|
||||
url: `${API_BASE}/notifications/upload-image`,
|
||||
filePath,
|
||||
name: 'file',
|
||||
header: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data)
|
||||
if (data.code === 0) {
|
||||
resolve(data.data)
|
||||
} else {
|
||||
reject(data)
|
||||
}
|
||||
} catch {
|
||||
reject({ message: '上传失败' })
|
||||
}
|
||||
},
|
||||
fail: () => reject({ message: '网络错误' })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取通知图片完整 URL */
|
||||
export function getNotificationImageUrl(filename: string): string {
|
||||
if (!filename) return ''
|
||||
// 如果已经是完整 URL 或 blob URL,直接返回
|
||||
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
|
||||
return `${API_BASE}/notifications/image/${filename}`
|
||||
}
|
||||
|
||||
/** 发布公告 */
|
||||
export function publishNotification(data: PublishParams) {
|
||||
return request<{ id: number }>({ url: '/notifications', method: 'POST', data })
|
||||
|
||||
64
client/src/api/recurring.ts
Normal file
64
client/src/api/recurring.ts
Normal 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' })
|
||||
}
|
||||
@@ -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
51
client/src/api/tag.ts
Normal 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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 交易列表响应 */
|
||||
@@ -40,6 +48,7 @@ export interface TransactionParams {
|
||||
minAmount?: number
|
||||
maxAmount?: number
|
||||
keyword?: string
|
||||
tagId?: number
|
||||
}
|
||||
|
||||
/** 获取交易列表 */
|
||||
@@ -60,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 })
|
||||
}
|
||||
@@ -71,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 })
|
||||
}
|
||||
@@ -79,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 })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface UserInfo {
|
||||
id: number
|
||||
nickname: string
|
||||
avatar_url: string
|
||||
slogan: string
|
||||
role: 'user' | 'admin'
|
||||
created_at: string
|
||||
}
|
||||
@@ -15,9 +16,11 @@ export function getUserInfo() {
|
||||
return request<UserInfo>({ url: '/user/me' })
|
||||
}
|
||||
|
||||
/** 更新昵称 */
|
||||
export function updateNickname(nickname: string) {
|
||||
return request({ url: '/user/me', method: 'PUT', data: { nickname } })
|
||||
/** 更新昵称/签名 */
|
||||
export function updateNickname(nickname: string, slogan?: string) {
|
||||
const data: any = { nickname }
|
||||
if (slogan !== undefined) data.slogan = slogan
|
||||
return request({ url: '/user/me', method: 'PUT', data })
|
||||
}
|
||||
|
||||
/** 上传头像(返回新 URL) */
|
||||
|
||||
275
client/src/components/AmountEditor/AmountEditor.vue
Normal file
275
client/src/components/AmountEditor/AmountEditor.vue
Normal 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>
|
||||
118
client/src/components/AmountEditor/amount-edit.ts
Normal file
118
client/src/components/AmountEditor/amount-edit.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
47
client/src/components/ColorPicker/ColorPicker.vue
Normal file
47
client/src/components/ColorPicker/ColorPicker.vue
Normal 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>
|
||||
144
client/src/components/DragSortList/DragSortList.vue
Normal file
144
client/src/components/DragSortList/DragSortList.vue
Normal 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>
|
||||
146
client/src/components/EditModal/EditModal.vue
Normal file
146
client/src/components/EditModal/EditModal.vue
Normal 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>
|
||||
@@ -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',
|
||||
@@ -71,6 +72,25 @@ const iconMap: Record<string, Record<string, string>> = {
|
||||
'#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(() => {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<view class="numpad">
|
||||
<view class="numpad" :class="{ 'numpad-fixed': fixed, 'numpad-hidden': !visible }">
|
||||
<view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)">
|
||||
<text class="key-text">{{ key }}</text>
|
||||
</view>
|
||||
<view class="key fn" @tap="onDelete">
|
||||
<view
|
||||
class="key fn"
|
||||
@tap="onDeleteTap"
|
||||
@touchstart="onDeleteTouchStart"
|
||||
@touchend="onDeleteTouchEnd"
|
||||
@touchcancel="onDeleteTouchEnd"
|
||||
>
|
||||
<view class="backspace-icon">
|
||||
<view class="backspace-arrow"></view>
|
||||
<view class="backspace-line"></view>
|
||||
@@ -16,73 +22,118 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount } from 'vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import {
|
||||
applyAmountKey,
|
||||
clampCursorIndex,
|
||||
type AmountKey
|
||||
} from '@/components/AmountEditor/amount-edit'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 金额字符串(v-model) */
|
||||
modelValue?: string
|
||||
/** 光标位置 */
|
||||
cursorIndex?: number
|
||||
/** 最大金额(元) */
|
||||
max?: number
|
||||
/** 是否固定在底部(弹窗中使用时设为 false) */
|
||||
fixed?: boolean
|
||||
/** 是否显示(fixed 模式下控制滑入/滑出) */
|
||||
visible?: boolean
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
max: 9999999.99
|
||||
max: 9999999.99,
|
||||
fixed: true,
|
||||
visible: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'update:cursorIndex', index: number): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
|
||||
const keys: AmountKey[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
|
||||
|
||||
const amountStr = computed(() => props.modelValue)
|
||||
|
||||
const effectiveCursorIndex = computed(() =>
|
||||
clampCursorIndex(amountStr.value, props.cursorIndex ?? amountStr.value.length)
|
||||
)
|
||||
|
||||
let deleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let deleteLongPressed = false
|
||||
|
||||
function vibrate() {
|
||||
try { uni.vibrateShort({ type: 'light' }) } catch {}
|
||||
}
|
||||
|
||||
function emitValue(val: string) {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
function onKeyTap(key: string) {
|
||||
function applyKey(key: AmountKey | 'delete') {
|
||||
vibrate()
|
||||
const current = amountStr.value
|
||||
|
||||
if (key === '.') {
|
||||
if (current.includes('.')) return
|
||||
if (!current || current === '0') { emitValue('0.'); return }
|
||||
}
|
||||
|
||||
let next: string
|
||||
if (!current || current === '0') {
|
||||
next = (key === '0' || key === '00') ? '0' : key
|
||||
} else {
|
||||
next = current + key
|
||||
}
|
||||
|
||||
if (next.includes('.')) {
|
||||
const decimals = next.split('.')[1]
|
||||
if (decimals && decimals.length > 2) return
|
||||
}
|
||||
if (next.length > 10) return
|
||||
const num = parseFloat(next)
|
||||
if (!isNaN(num) && num > props.max) return
|
||||
|
||||
emitValue(next)
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
vibrate()
|
||||
const current = amountStr.value
|
||||
if (current.length > 1) {
|
||||
emitValue(current.slice(0, -1))
|
||||
} else {
|
||||
emitValue('')
|
||||
const result = applyAmountKey(
|
||||
amountStr.value,
|
||||
effectiveCursorIndex.value,
|
||||
key,
|
||||
{ max: props.max }
|
||||
)
|
||||
if (result.accepted) {
|
||||
emit('update:modelValue', result.value)
|
||||
emit('update:cursorIndex', result.cursorIndex)
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyTap(key: AmountKey) {
|
||||
applyKey(key)
|
||||
}
|
||||
|
||||
function onDeleteTap() {
|
||||
if (deleteLongPressed) {
|
||||
deleteLongPressed = false
|
||||
return
|
||||
}
|
||||
applyKey('delete')
|
||||
}
|
||||
|
||||
function doDeleteRepeat() {
|
||||
deleteLongPressed = true
|
||||
const result = applyAmountKey(
|
||||
amountStr.value,
|
||||
effectiveCursorIndex.value,
|
||||
'delete',
|
||||
{ max: props.max }
|
||||
)
|
||||
if (result.accepted) {
|
||||
vibrate()
|
||||
emit('update:modelValue', result.value)
|
||||
emit('update:cursorIndex', result.cursorIndex)
|
||||
deleteTimer = setTimeout(doDeleteRepeat, 120)
|
||||
} else {
|
||||
deleteLongPressed = false
|
||||
deleteTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDeleteTouchStart() {
|
||||
if (deleteTimer !== null) clearTimeout(deleteTimer)
|
||||
deleteLongPressed = false
|
||||
deleteTimer = setTimeout(doDeleteRepeat, 400)
|
||||
}
|
||||
|
||||
function onDeleteTouchEnd() {
|
||||
if (deleteTimer !== null) {
|
||||
clearTimeout(deleteTimer)
|
||||
deleteTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (deleteTimer !== null) {
|
||||
clearTimeout(deleteTimer)
|
||||
deleteTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
function onConfirm() {
|
||||
vibrate()
|
||||
emit('confirm')
|
||||
@@ -90,40 +141,91 @@ function onConfirm() {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins.scss';
|
||||
|
||||
.numpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16rpx;
|
||||
padding: 0 32rpx;
|
||||
gap: $space-sm;
|
||||
padding: $space-sm $space-xl;
|
||||
|
||||
// 非 fixed 模式的显示/隐藏
|
||||
&:not(.numpad-fixed) {
|
||||
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s ease;
|
||||
overflow: hidden;
|
||||
max-height: 600rpx;
|
||||
|
||||
&.numpad-hidden {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// fixed 模式的滑入/滑出
|
||||
&.numpad-fixed {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: $bg;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
z-index: 100;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.numpad-hidden {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.key {
|
||||
height: 96rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #FFF8F0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $radius-xl;
|
||||
background: $surface;
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-clay;
|
||||
@include flex-center;
|
||||
transition: all $transition-fast;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
|
||||
&:active { background: #F0E0D6; }
|
||||
&:active {
|
||||
background: $surface-warm;
|
||||
box-shadow: $shadow-clay-pressed;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.key-text {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 44rpx;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.key.fn {
|
||||
background: #FFE8E0;
|
||||
&:active { background: #FFD0C0; }
|
||||
background: $primary-light;
|
||||
border-color: 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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
<template>
|
||||
<view class="save-success" v-if="visible" @tap="hide">
|
||||
<view class="success-icon" :class="{ show: animating }">
|
||||
<Icon name="check" :size="80" color="#FFFFFF" />
|
||||
<view class="save-success" v-if="visible">
|
||||
<!-- 纸屑粒子 -->
|
||||
<view class="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>
|
||||
</view>
|
||||
<text class="success-text">{{ text }}</text>
|
||||
<view class="success-actions" v-if="showContinue">
|
||||
<view class="action-btn continue" @tap="onContinue">
|
||||
<text class="action-text">继续记一笔</text>
|
||||
</view>
|
||||
<view class="action-btn back" @tap="onBack">
|
||||
<text class="action-text">返回</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="success-text" :class="{ show: animating }">{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -14,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
65
client/src/components/Snackbar/Snackbar.vue
Normal file
65
client/src/components/Snackbar/Snackbar.vue
Normal 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>
|
||||
@@ -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">
|
||||
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
|
||||
<view class="name-row">
|
||||
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
|
||||
</view>
|
||||
<view class="meta">
|
||||
<view 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>
|
||||
|
||||
39
client/src/composables/usePageReady.ts
Normal file
39
client/src/composables/usePageReady.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"path": "pages/add/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "记一笔"
|
||||
"navigationBarTitleText": "记一笔",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -35,35 +36,47 @@
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -71,14 +84,15 @@
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "通知中心",
|
||||
"enablePullDownRefresh": false
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "管理后台"
|
||||
"navigationBarTitleText": "管理后台",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -86,8 +100,86 @@
|
||||
"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": "数据导入"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
@@ -95,7 +187,8 @@
|
||||
"navigationBarTitleText": "小菜记账",
|
||||
"navigationBarBackgroundColor": "#FFF8F0",
|
||||
"backgroundColor": "#FFF8F0",
|
||||
"backgroundTextStyle": "dark"
|
||||
"backgroundTextStyle": "dark",
|
||||
"enablePullDownRefresh": true
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#BFB3B3",
|
||||
|
||||
@@ -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">
|
||||
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
|
||||
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id, '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 () => {
|
||||
await waitForReady()
|
||||
await catStore.fetchCategories()
|
||||
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)
|
||||
})
|
||||
@@ -155,15 +316,22 @@ onMounted(async () => {
|
||||
// 返回页面时刷新分类(可能新增了分类)
|
||||
const initialLoaded = ref(false)
|
||||
onShow(() => {
|
||||
if (initialLoaded.value) catStore.fetchCategories()
|
||||
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' })
|
||||
@@ -181,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)
|
||||
@@ -189,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 {
|
||||
@@ -197,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({
|
||||
@@ -216,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;
|
||||
}
|
||||
|
||||
@@ -270,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;
|
||||
@@ -334,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 {
|
||||
@@ -344,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;
|
||||
@@ -364,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>
|
||||
|
||||
257
client/src/pages/admin/config.vue
Normal file
257
client/src/pages/admin/config.vue
Normal 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>
|
||||
609
client/src/pages/admin/feedback.vue
Normal file
609
client/src/pages/admin/feedback.vue
Normal 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>
|
||||
@@ -42,16 +42,43 @@
|
||||
<view class="detail-card">
|
||||
<text class="detail-title">本月概况</text>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">交易总额</text>
|
||||
<text class="detail-value">{{ formatAmount(dashboard.monthlyTxAmount) }}</text>
|
||||
<text class="detail-label">支出总额</text>
|
||||
<text class="detail-value expense">{{ formatAmount(dashboard.monthlyExpense || 0) }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">收入总额</text>
|
||||
<text class="detail-value income">{{ formatAmount(dashboard.monthlyIncome || 0) }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">月活用户</text>
|
||||
<text class="detail-value">{{ dashboard.monthlyActive }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">上月交易</text>
|
||||
<text class="detail-value">{{ dashboard.lastMonthTxCount }} 笔 / {{ formatAmount(dashboard.lastMonthTxAmount) }}</text>
|
||||
<text class="detail-label">上月支出</text>
|
||||
<text class="detail-value">{{ formatAmount(dashboard.lastMonthExpense || 0) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 健康状态 -->
|
||||
<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>
|
||||
|
||||
@@ -63,78 +90,30 @@
|
||||
<text class="entry-text">用户管理</text>
|
||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||
</view>
|
||||
<view class="entry-item" @tap="goNotifications">
|
||||
<view class="entry-item" @tap="goNotificationManage">
|
||||
<Icon name="bell" :size="32" color="#FF8C69" />
|
||||
<text class="entry-text">发布公告</text>
|
||||
<text class="entry-text">公告管理</text>
|
||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发布公告弹窗 -->
|
||||
<view class="modal-mask" v-if="showPublishModal" @tap="showPublishModal = false">
|
||||
<view class="publish-modal" @tap.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">发布公告</text>
|
||||
<view class="modal-close" @tap="showPublishModal = false">
|
||||
<text class="modal-close-text">×</text>
|
||||
</view>
|
||||
<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>
|
||||
<scroll-view class="modal-body" scroll-y>
|
||||
<view class="form-group">
|
||||
<text class="form-label">标题 *</text>
|
||||
<input class="form-input" v-model="publishForm.title" placeholder="输入公告标题" maxlength="100" />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">内容</text>
|
||||
<textarea class="form-textarea" v-model="publishForm.content" placeholder="输入公告内容" maxlength="2000" />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">链接(可选)</text>
|
||||
<input class="form-input" v-model="publishForm.link_url" placeholder="https://..." />
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">配图(可选)</text>
|
||||
<view v-if="publishForm.image_url" class="image-preview">
|
||||
<image class="preview-img" :src="publishForm.image_url" mode="aspectFill" />
|
||||
<view class="image-remove" @tap="publishForm.image_url = ''">
|
||||
<text class="image-remove-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="image-upload" @tap="chooseImage">
|
||||
<Icon name="plus" :size="40" color="#BFB3B3" />
|
||||
<text class="upload-text">选择图片</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">定时发布(可选)</text>
|
||||
<picker mode="date" :value="publishForm.publish_at" @change="e => publishForm.publish_at = e.detail.value">
|
||||
<view class="form-input picker-input">
|
||||
<text :class="{ placeholder: !publishForm.publish_at }">{{ publishForm.publish_at || '立即发布' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-group">
|
||||
<text class="form-label">过期时间(可选)</text>
|
||||
<picker mode="date" :value="publishForm.expire_at" @change="e => publishForm.expire_at = e.detail.value">
|
||||
<view class="form-input picker-input">
|
||||
<text :class="{ placeholder: !publishForm.expire_at }">{{ publishForm.expire_at || '永不过期' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<view class="switch-item" @tap="publishForm.is_pinned = !publishForm.is_pinned">
|
||||
<text class="switch-label">📌 置顶</text>
|
||||
<view class="switch-toggle" :class="{ on: publishForm.is_pinned }"></view>
|
||||
</view>
|
||||
<view class="switch-item" @tap="publishForm.is_urgent = !publishForm.is_urgent">
|
||||
<text class="switch-label">🔔 强提醒</text>
|
||||
<view class="switch-toggle" :class="{ on: publishForm.is_urgent }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="modal-footer" @tap="handlePublish">
|
||||
<text class="publish-btn-text">发布</text>
|
||||
<view class="entry-item" @tap="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>
|
||||
@@ -143,19 +122,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
import { getDashboard } from '@/api/admin'
|
||||
import { publishNotification } from '@/api/notification'
|
||||
import { 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 () => {
|
||||
await waitForReady()
|
||||
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||
|
||||
try {
|
||||
dashboard.value = await getDashboard()
|
||||
} catch (e: any) {
|
||||
@@ -163,99 +146,81 @@ onMounted(async () => {
|
||||
} 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' }) }
|
||||
const showPublishModal = ref(false)
|
||||
const publishForm = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
is_pinned: false,
|
||||
is_urgent: false,
|
||||
link_url: '',
|
||||
image_url: '',
|
||||
publish_at: '',
|
||||
expire_at: ''
|
||||
})
|
||||
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 goNotifications() {
|
||||
publishForm.value = { title: '', content: '', is_pinned: false, is_urgent: false, link_url: '', image_url: '', publish_at: '', expire_at: '' }
|
||||
showPublishModal.value = true
|
||||
}
|
||||
|
||||
/** 选择配图 */
|
||||
function chooseImage() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
success: (res) => {
|
||||
publishForm.value.image_url = res.tempFilePaths[0]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!publishForm.value.title.trim()) {
|
||||
uni.showToast({ title: '请输入标题', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await publishNotification({
|
||||
title: publishForm.value.title.trim(),
|
||||
content: publishForm.value.content.trim(),
|
||||
is_pinned: publishForm.value.is_pinned,
|
||||
is_urgent: publishForm.value.is_urgent,
|
||||
link_url: publishForm.value.link_url.trim() || undefined,
|
||||
image_url: publishForm.value.image_url || undefined,
|
||||
publish_at: publishForm.value.publish_at || undefined,
|
||||
expire_at: publishForm.value.expire_at || undefined
|
||||
})
|
||||
showPublishModal.value = false
|
||||
uni.showToast({ title: '已发布', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e.message || '发布失败', icon: 'none' })
|
||||
}
|
||||
function 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 {
|
||||
min-height: 100vh;
|
||||
background: #FFF8F0;
|
||||
padding: 0 0 180rpx;
|
||||
@include page-base;
|
||||
}
|
||||
|
||||
.header-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #FFF8F0;
|
||||
@include sticky-header;
|
||||
}
|
||||
|
||||
.status-bar { background: #FFF8F0; }
|
||||
.status-bar { @include status-bar; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 40rpx 24rpx;
|
||||
padding: $space-sm $space-xl $space-md;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.6; }
|
||||
@include nav-back;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-size: $font-2xl;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.nav-placeholder { width: 88rpx; }
|
||||
@@ -268,60 +233,60 @@ async function handlePublish() {
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
font-size: $font-lg;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.content { padding: 0 40rpx; }
|
||||
.content { padding: 0 $space-xl; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16rpx;
|
||||
margin-bottom: 32rpx;
|
||||
gap: $space-sm;
|
||||
margin-bottom: $space-lg;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
background: $surface;
|
||||
border-radius: $radius-xl;
|
||||
padding: $font-lg $space-md;
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 40rpx;
|
||||
font-size: $font-3xl;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
display: block;
|
||||
|
||||
&.up { color: #7BC67E; }
|
||||
&.down { color: #FF6B6B; }
|
||||
&.up { color: $success; }
|
||||
&.down { color: $danger; }
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
font-size: $font-md;
|
||||
color: $text-sec;
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
margin-top: $space-xs;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
padding: 32rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
margin-bottom: 32rpx;
|
||||
background: $surface;
|
||||
border-radius: $radius-xl;
|
||||
padding: $space-lg;
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-md;
|
||||
margin-bottom: $space-lg;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 28rpx;
|
||||
font-size: $font-lg;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
margin-bottom: $space-lg;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
@@ -335,262 +300,101 @@ async function handlePublish() {
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 26rpx;
|
||||
color: #8B7E7E;
|
||||
font-size: $font-base;
|
||||
color: $text-sec;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 26rpx;
|
||||
font-size: $font-base;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
|
||||
&.expense { color: $danger; }
|
||||
&.income { color: $success; }
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-size: $font-lg;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
margin-bottom: 16rpx;
|
||||
color: $text;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.entry-list { margin-bottom: 32rpx; }
|
||||
.entry-list { margin-bottom: $space-lg; }
|
||||
|
||||
.entry-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
margin-bottom: 16rpx;
|
||||
gap: $space-lg;
|
||||
padding: $font-lg $space-lg;
|
||||
background: $surface;
|
||||
border-radius: $radius-xl;
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-md;
|
||||
margin-bottom: $space-sm;
|
||||
|
||||
&:active { background: #FFF8F0; }
|
||||
&:active { background: $bg; }
|
||||
}
|
||||
|
||||
.entry-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
font-size: $font-lg;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
/* 发布公告弹窗 */
|
||||
.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;
|
||||
/* 健康状态卡片 */
|
||||
.health-card {
|
||||
background: $surface;
|
||||
border-radius: $radius-xl;
|
||||
padding: $space-lg;
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-md;
|
||||
margin-bottom: $space-lg;
|
||||
}
|
||||
|
||||
.publish-modal {
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
background: #FFFFFF;
|
||||
border-radius: 40rpx 40rpx 0 0;
|
||||
.health-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx 40rpx 24rpx;
|
||||
border-bottom: 2rpx solid #F0E0D6;
|
||||
align-items: center;
|
||||
margin-bottom: $space-md;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
.health-title {
|
||||
font-size: $font-lg;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
.health-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: 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%;
|
||||
background: #F0E0D6;
|
||||
&:active { background: #E0D6D0; }
|
||||
|
||||
.ok & { background: $success; }
|
||||
.degraded & { background: #FFD700; }
|
||||
.error & { background: $danger; }
|
||||
}
|
||||
|
||||
.modal-close-text {
|
||||
font-size: 36rpx;
|
||||
color: #8B7E7E;
|
||||
line-height: 1;
|
||||
}
|
||||
.health-badge-text {
|
||||
font-size: $font-sm;
|
||||
font-weight: 500;
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 24rpx 40rpx;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
padding: 0 24rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
font-size: 28rpx;
|
||||
color: #2D1B1B;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
height: 200rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
font-size: 28rpx;
|
||||
color: #2D1B1B;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.switch-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #FFF8F0;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
|
||||
&:active { background: #F0E0D6; }
|
||||
}
|
||||
|
||||
.switch-label {
|
||||
font-size: 26rpx;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.switch-toggle {
|
||||
width: 44rpx;
|
||||
height: 24rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #D0C4C4;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
|
||||
&.on { background: #FF8C69; }
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2rpx;
|
||||
left: 2rpx;
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
background: #FFFFFF;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
&.on::after { transform: translateX(20rpx); }
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 24rpx 40rpx 40rpx;
|
||||
border-top: 2rpx solid #F0E0D6;
|
||||
}
|
||||
|
||||
.publish-btn-text {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
padding: 20rpx;
|
||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
||||
border-radius: 20rpx;
|
||||
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.image-upload {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border: 2rpx dashed #D0C4C4;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
|
||||
&:active { background: #FFF8F0; }
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 24rpx;
|
||||
color: #BFB3B3;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.image-remove {
|
||||
position: absolute;
|
||||
top: -12rpx;
|
||||
right: -12rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background: #FF6B6B;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-remove-text {
|
||||
font-size: 28rpx;
|
||||
color: #FFFFFF;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.picker-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.placeholder { color: #BFB3B3; }
|
||||
.ok & { color: $success; }
|
||||
.degraded & { color: #CC9900; }
|
||||
.error & { color: $danger; }
|
||||
}
|
||||
</style>
|
||||
|
||||
501
client/src/pages/admin/logs.vue
Normal file
501
client/src/pages/admin/logs.vue
Normal 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>
|
||||
814
client/src/pages/admin/notifications.vue
Normal file
814
client/src/pages/admin/notifications.vue
Normal 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>
|
||||
659
client/src/pages/admin/track.vue
Normal file
659
client/src/pages/admin/track.vue
Normal 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>
|
||||
@@ -54,10 +54,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onReachBottom } from '@dcloudio/uni-app'
|
||||
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
import { API_BASE } from '@/config'
|
||||
import { getAvatarUrl } from '@/utils/avatar'
|
||||
import { getUsers, updateUserRole, deleteUser } from '@/api/admin'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
@@ -71,9 +71,22 @@ const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const initialLoaded = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
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) {
|
||||
@@ -83,7 +96,7 @@ async function loadUsers(reset = false) {
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, pageSize: 20 }
|
||||
const params: { page: number; pageSize: number; keyword?: string } = { page: page.value, pageSize: 20 }
|
||||
if (keyword.value.trim()) params.keyword = keyword.value.trim()
|
||||
const data = await getUsers(params)
|
||||
if (reset) {
|
||||
@@ -99,12 +112,6 @@ async function loadUsers(reset = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function getAvatarUrl(avatarUrl: string): string {
|
||||
if (!avatarUrl) return ''
|
||||
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
|
||||
return `${API_BASE}/user/avatar/${avatarUrl}`
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
@@ -141,7 +148,7 @@ function handleDelete(user: AdminUser) {
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await deleteUser(user.id)
|
||||
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) {
|
||||
@@ -162,86 +169,77 @@ onReachBottom(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #FFF8F0;
|
||||
padding: 0 0 180rpx;
|
||||
@include page-base;
|
||||
}
|
||||
|
||||
.header-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #FFF8F0;
|
||||
@include sticky-header;
|
||||
}
|
||||
|
||||
.status-bar { background: #FFF8F0; }
|
||||
.status-bar { @include status-bar; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 40rpx 24rpx;
|
||||
padding: $space-sm $space-xl $space-md;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.6; }
|
||||
@include nav-back;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-size: $font-2xl;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.nav-placeholder { width: 88rpx; }
|
||||
|
||||
.search-bar {
|
||||
padding: 0 40rpx 24rpx;
|
||||
padding: 0 $space-xl $space-md;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
font-size: 28rpx;
|
||||
color: #2D1B1B;
|
||||
padding: 0 $space-md;
|
||||
background: $surface;
|
||||
border-radius: $radius-lg;
|
||||
border: 2rpx solid $border;
|
||||
font-size: $font-lg;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.loading-box, .empty-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
gap: $space-sm;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.loading-text, .empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
font-size: $font-lg;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.user-list { padding: 0 40rpx; }
|
||||
.user-list { padding: 0 $space-xl; }
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
margin-bottom: 16rpx;
|
||||
gap: $space-lg;
|
||||
padding: $space-md $font-lg;
|
||||
background: $surface;
|
||||
border-radius: $radius-xl;
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-md;
|
||||
margin-bottom: $space-sm;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -249,7 +247,7 @@ onReachBottom(() => {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: #F0E0D6;
|
||||
background: $border;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -262,9 +260,9 @@ onReachBottom(() => {
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 28rpx;
|
||||
font-size: $font-lg;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -272,24 +270,24 @@ onReachBottom(() => {
|
||||
|
||||
.role-tag {
|
||||
padding: 2rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
border-radius: $radius-xs;
|
||||
}
|
||||
|
||||
.role-tag-text {
|
||||
font-size: 20rpx;
|
||||
font-size: $font-xs;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
font-size: $font-md;
|
||||
color: $text-sec;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.user-time {
|
||||
font-size: 22rpx;
|
||||
color: #BFB3B3;
|
||||
font-size: $font-sm;
|
||||
color: $text-muted;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
@@ -297,23 +295,23 @@ onReachBottom(() => {
|
||||
.user-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
gap: $space-xs;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8rpx 16rpx;
|
||||
background: #FFF0E6;
|
||||
border-radius: 12rpx;
|
||||
padding: $space-xs $space-sm;
|
||||
background: $surface-warm;
|
||||
border-radius: $radius-sm;
|
||||
text-align: center;
|
||||
|
||||
&.danger { background: #FFE8E8; }
|
||||
&.danger { background: $danger-light; }
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 22rpx;
|
||||
color: #FF8C69;
|
||||
&.danger-text { color: #FF6B6B; }
|
||||
font-size: $font-sm;
|
||||
color: $primary;
|
||||
&.danger-text { color: $danger; }
|
||||
}
|
||||
</style>
|
||||
|
||||
302
client/src/pages/backup-manage/index.vue
Normal file
302
client/src/pages/backup-manage/index.vue
Normal 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>
|
||||
@@ -213,24 +213,21 @@ function handleReset() {
|
||||
|
||||
function handleApply() {
|
||||
const filters: FilterParams = { ...localFilters };
|
||||
// 转换金额:元 → 分
|
||||
filters.minAmount = minAmountStr.value
|
||||
? Math.round(parseFloat(minAmountStr.value) * 100)
|
||||
: 0;
|
||||
filters.maxAmount = maxAmountStr.value
|
||||
? Math.round(parseFloat(maxAmountStr.value) * 100)
|
||||
: 0;
|
||||
// 转换金额:元 → 分,防御 NaN
|
||||
const minVal = parseFloat(minAmountStr.value);
|
||||
const maxVal = parseFloat(maxAmountStr.value);
|
||||
filters.minAmount = minAmountStr.value && !isNaN(minVal) ? Math.round(minVal * 100) : 0;
|
||||
filters.maxAmount = maxAmountStr.value && !isNaN(maxVal) ? Math.round(maxVal * 100) : 0;
|
||||
emit("apply", filters);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const filters: FilterParams = { ...localFilters };
|
||||
filters.minAmount = minAmountStr.value
|
||||
? Math.round(parseFloat(minAmountStr.value) * 100)
|
||||
: 0;
|
||||
filters.maxAmount = maxAmountStr.value
|
||||
? Math.round(parseFloat(maxAmountStr.value) * 100)
|
||||
: 0;
|
||||
// 转换金额:元 → 分,防御 NaN
|
||||
const minVal = parseFloat(minAmountStr.value);
|
||||
const maxVal = parseFloat(maxAmountStr.value);
|
||||
filters.minAmount = minAmountStr.value && !isNaN(minVal) ? Math.round(minVal * 100) : 0;
|
||||
filters.maxAmount = maxAmountStr.value && !isNaN(maxVal) ? Math.round(maxVal * 100) : 0;
|
||||
|
||||
uni.showModal({
|
||||
title: "保存方案",
|
||||
@@ -271,26 +268,19 @@ async function handleDeleteSaved(id: number) {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins.scss';
|
||||
|
||||
.filter-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@include modal-mask;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
background: #ffffff;
|
||||
border-radius: 40rpx 40rpx 0 0;
|
||||
background: $surface;
|
||||
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -298,75 +288,70 @@ async function handleDeleteSaved(id: number) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx 40rpx 24rpx;
|
||||
border-bottom: 2rpx solid #f0e0d6;
|
||||
padding: $space-lg $space-xl $space-md;
|
||||
border-bottom: 2rpx solid $border;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
font-size: 32rpx;
|
||||
font-size: $font-xl;
|
||||
font-weight: 600;
|
||||
color: #2d1b1b;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.filter-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@include flex-center;
|
||||
border-radius: 50%;
|
||||
background: #f0e0d6;
|
||||
background: $border;
|
||||
&:active {
|
||||
background: #e0d6d0;
|
||||
background: darken($border, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
.filter-close-text {
|
||||
font-size: 36rpx;
|
||||
color: #8b7e7e;
|
||||
font-size: $font-2xl;
|
||||
color: $text-sec;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.filter-body {
|
||||
flex: 1;
|
||||
padding: 24rpx 40rpx;
|
||||
padding: $space-md $space-xl;
|
||||
max-height: 55vh;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 32rpx;
|
||||
margin-bottom: $space-lg;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 26rpx;
|
||||
font-size: $font-base;
|
||||
font-weight: 600;
|
||||
color: #2d1b1b;
|
||||
color: $text;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
gap: $space-xs;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 12rpx 24rpx;
|
||||
background: #fff8f0;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #f0e0d6;
|
||||
transition: all 0.2s;
|
||||
padding: $space-xs $space-md;
|
||||
background: $bg;
|
||||
border-radius: $radius-xl;
|
||||
border: 2rpx solid $border;
|
||||
transition: all $transition-normal;
|
||||
|
||||
&.active {
|
||||
background: #ffe8e0;
|
||||
border-color: #ff8c69;
|
||||
background: $primary-light;
|
||||
border-color: $primary;
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -375,10 +360,10 @@ async function handleDeleteSaved(id: number) {
|
||||
}
|
||||
|
||||
.chip-text {
|
||||
font-size: 24rpx;
|
||||
color: #2d1b1b;
|
||||
font-size: $font-md;
|
||||
color: $text;
|
||||
.active & {
|
||||
color: #ff8c69;
|
||||
color: $primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -386,130 +371,123 @@ async function handleDeleteSaved(id: number) {
|
||||
.date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
gap: $space-sm;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
background: #fff8f0;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #f0e0d6;
|
||||
padding: 0 $space-md;
|
||||
background: $bg;
|
||||
border-radius: $radius-lg;
|
||||
border: 2rpx solid $border;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-text {
|
||||
font-size: 26rpx;
|
||||
color: #2d1b1b;
|
||||
font-size: $font-base;
|
||||
color: $text;
|
||||
&.placeholder {
|
||||
color: #bfb3b3;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.date-sep {
|
||||
font-size: 26rpx;
|
||||
color: #8b7e7e;
|
||||
font-size: $font-base;
|
||||
color: $text-sec;
|
||||
}
|
||||
|
||||
.amount-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
gap: $space-sm;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
background: #fff8f0;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #f0e0d6;
|
||||
font-size: 26rpx;
|
||||
color: #2d1b1b;
|
||||
padding: 0 $space-md;
|
||||
background: $bg;
|
||||
border-radius: $radius-lg;
|
||||
border: 2rpx solid $border;
|
||||
font-size: $font-base;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.amount-sep {
|
||||
font-size: 26rpx;
|
||||
color: #8b7e7e;
|
||||
font-size: $font-base;
|
||||
color: $text-sec;
|
||||
}
|
||||
|
||||
.keyword-input {
|
||||
width: 100%;
|
||||
height: 72rpx;
|
||||
padding: 0 20rpx;
|
||||
background: #fff8f0;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #f0e0d6;
|
||||
font-size: 26rpx;
|
||||
color: #2d1b1b;
|
||||
padding: 0 $space-md;
|
||||
background: $bg;
|
||||
border-radius: $radius-lg;
|
||||
border: 2rpx solid $border;
|
||||
font-size: $font-base;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.saved-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 20rpx;
|
||||
background: #fff8f0;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 8rpx;
|
||||
padding: $space-sm $space-md;
|
||||
background: $bg;
|
||||
border-radius: $radius-lg;
|
||||
margin-bottom: $space-xs;
|
||||
|
||||
&:active {
|
||||
background: #f0e0d6;
|
||||
background: $border;
|
||||
}
|
||||
}
|
||||
|
||||
.saved-name {
|
||||
font-size: 26rpx;
|
||||
color: #2d1b1b;
|
||||
font-size: $font-base;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.saved-delete {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@include flex-center;
|
||||
&:active {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.saved-delete-text {
|
||||
font-size: 28rpx;
|
||||
color: #bfb3b3;
|
||||
font-size: $font-lg;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.filter-footer {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx 40rpx 40rpx;
|
||||
border-top: 2rpx solid #f0e0d6;
|
||||
gap: $space-sm;
|
||||
padding: $space-md $space-xl $space-xl;
|
||||
border-top: 2rpx solid $border;
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $radius-xl;
|
||||
@include flex-center;
|
||||
|
||||
&.reset {
|
||||
background: #fff8f0;
|
||||
border: 2rpx solid #f0e0d6;
|
||||
background: $bg;
|
||||
border: 2rpx solid $border;
|
||||
}
|
||||
&.save {
|
||||
background: #fff0e6;
|
||||
border: 2rpx solid #ff8c69;
|
||||
background: $surface-warm;
|
||||
border: 2rpx solid $primary;
|
||||
}
|
||||
&.apply {
|
||||
background: linear-gradient(135deg, #ff8c69, #e67355);
|
||||
background: linear-gradient(135deg, $primary, $primary-dark);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -518,16 +496,16 @@ async function handleDeleteSaved(id: number) {
|
||||
}
|
||||
|
||||
.footer-btn-text {
|
||||
font-size: 28rpx;
|
||||
font-size: $font-lg;
|
||||
font-weight: 600;
|
||||
&.reset-text {
|
||||
color: #8b7e7e;
|
||||
color: $text-sec;
|
||||
}
|
||||
&.save-text {
|
||||
color: #ff8c69;
|
||||
color: $primary;
|
||||
}
|
||||
&.apply-text {
|
||||
color: #ffffff;
|
||||
color: $surface;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,25 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 标签筛选条 -->
|
||||
<scroll-view class="tag-filter-scroll" scroll-x v-if="tagStore.tags.length > 0">
|
||||
<view class="tag-filter-row">
|
||||
<view class="tag-filter-chip" :class="{ active: filterTagId === null }" @tap="filterTagId = null; loadTx(true)">
|
||||
<text class="tag-filter-text">全部标签</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="tag in tagStore.tags"
|
||||
:key="tag.id"
|
||||
class="tag-filter-chip"
|
||||
:class="{ active: filterTagId === tag.id }"
|
||||
@tap="filterTagId = tag.id; loadTx(true)"
|
||||
>
|
||||
<view class="tag-filter-dot" :style="{ background: tag.color }"></view>
|
||||
<text class="tag-filter-text">{{ tag.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 筛选结果统计 -->
|
||||
<view class="filter-stats" v-if="hasAdvancedFilter && !loading">
|
||||
<text class="filter-stats-text">共 {{ total }} 笔</text>
|
||||
@@ -27,7 +46,7 @@
|
||||
<!-- 筛选面板 -->
|
||||
<FilterPanel v-if="showFilter" :initialFilters="advancedFilters" @close="showFilter = false" @apply="onFilterApply" />
|
||||
|
||||
<view class="tx-list" v-if="!txStore.loading || txList.length > 0">
|
||||
<view class="tx-list" v-if="!loading || txList.length > 0">
|
||||
<view v-for="group in groupedTx" :key="group.date" class="date-group">
|
||||
<view class="group-header">
|
||||
<text class="group-date">{{ group.label }}</text>
|
||||
@@ -43,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" />
|
||||
@@ -66,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>
|
||||
|
||||
@@ -79,38 +100,86 @@
|
||||
<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>({})
|
||||
@@ -142,7 +211,14 @@ const groupedTx = computed(() => {
|
||||
const initialLoaded = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
try {
|
||||
await waitForReady()
|
||||
} catch {
|
||||
// waitForReady 超时:显示网络异常提示
|
||||
loadError.value = true
|
||||
return
|
||||
}
|
||||
tagStore.fetchTags().catch(() => {})
|
||||
loadTx(true).finally(() => { initialLoaded.value = true })
|
||||
})
|
||||
|
||||
@@ -152,6 +228,7 @@ onShow(() => {
|
||||
})
|
||||
|
||||
function switchFilter(type: string) {
|
||||
if (loading.value) return // 防止重复提交
|
||||
filterType.value = type
|
||||
loadTx(true)
|
||||
}
|
||||
@@ -162,7 +239,7 @@ async function loadTx(reset = false, silent = false) {
|
||||
if (!silent) txList.value = []
|
||||
}
|
||||
const seq = ++loadSeq
|
||||
const params: any = { page: page.value, pageSize }
|
||||
const params: TransactionParams & { group_id?: number | null; category_ids?: string } = { page: page.value, pageSize, group_id: groupStore.currentGroupId }
|
||||
if (filterType.value !== 'all') params.type = filterType.value
|
||||
// 合并高级筛选参数
|
||||
const af = advancedFilters.value
|
||||
@@ -172,25 +249,27 @@ async function loadTx(reset = false, silent = false) {
|
||||
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
|
||||
const data = await txStore.fetchTransactions(params)
|
||||
const data = await getTransactions(params)
|
||||
// 丢弃过期请求的结果
|
||||
if (seq !== loadSeq) return
|
||||
if (reset) {
|
||||
txList.value = txStore.transactions
|
||||
txList.value = data.list
|
||||
} else {
|
||||
txList.value = [...txList.value, ...txStore.transactions]
|
||||
}
|
||||
total.value = txStore.total
|
||||
if (data) {
|
||||
filteredExpense.value = data.filteredExpense || 0
|
||||
filteredIncome.value = data.filteredIncome || 0
|
||||
txList.value = [...txList.value, ...data.list]
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +281,7 @@ function onFilterApply(filters: FilterParams) {
|
||||
|
||||
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}` })
|
||||
@@ -213,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)
|
||||
txList.value = txList.value.filter(t => t.id !== item.id)
|
||||
total.value--
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// 乐观删除:先从列表移除,显示 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 = 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)
|
||||
})
|
||||
@@ -245,60 +345,36 @@ onPullDownRefresh(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #FFF8F0;
|
||||
padding: 0 0 180rpx;
|
||||
@include page-base;
|
||||
}
|
||||
|
||||
.header-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #FFF8F0;
|
||||
@include sticky-header;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: #FFF8F0;
|
||||
@include status-bar;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
padding: 16rpx 0 24rpx;
|
||||
@include page-title;
|
||||
}
|
||||
|
||||
.tabs-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@include tabs-wrap;
|
||||
justify-content: center;
|
||||
margin: 0 0 24rpx;
|
||||
gap: 16rpx;
|
||||
margin: 0 0 $space-md;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
background: #FFF0E6;
|
||||
border-radius: 24rpx;
|
||||
padding: 8rpx;
|
||||
@include tabs;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 16rpx 40rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #8B7E7E;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.active {
|
||||
background: #FFFFFF;
|
||||
color: #FF8C69;
|
||||
font-weight: 600;
|
||||
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
|
||||
}
|
||||
@include tab;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
@@ -306,104 +382,139 @@ onPullDownRefresh(() => {
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 12rpx 20rpx;
|
||||
background: #FFF0E6;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
background: $surface-warm;
|
||||
border-radius: $radius-lg;
|
||||
border: 2rpx solid $border;
|
||||
|
||||
&.active {
|
||||
border-color: #FF8C69;
|
||||
background: #FFE8E0;
|
||||
border-color: $primary;
|
||||
background: $primary-light;
|
||||
}
|
||||
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.filter-btn-text {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
.active & { color: #FF8C69; }
|
||||
font-size: $font-md;
|
||||
color: $text-sec;
|
||||
.active & { color: $primary; }
|
||||
}
|
||||
|
||||
.filter-stats {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 0 40rpx 16rpx;
|
||||
gap: $space-md;
|
||||
padding: 0 $space-xl $space-sm;
|
||||
}
|
||||
|
||||
.filter-stats-text {
|
||||
font-size: 24rpx;
|
||||
font-size: $font-md;
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
color: #8B7E7E;
|
||||
color: $text-sec;
|
||||
}
|
||||
|
||||
.tx-list { padding: 0 40rpx; }
|
||||
.tx-list { padding: 0 $space-xl; }
|
||||
|
||||
.date-group {
|
||||
margin-bottom: 24rpx;
|
||||
margin-bottom: $space-md;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1rpx solid #F0E0D6;
|
||||
margin-bottom: 8rpx;
|
||||
padding: $space-sm 0;
|
||||
border-bottom: 1rpx solid $border;
|
||||
margin-bottom: $space-xs;
|
||||
}
|
||||
|
||||
.group-date {
|
||||
font-size: 26rpx;
|
||||
font-size: $font-base;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.group-total {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
font-size: $font-md;
|
||||
color: $text-sec;
|
||||
|
||||
&.expense { color: #2D1B1B; }
|
||||
&.expense { color: $text; }
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 0;
|
||||
gap: 24rpx;
|
||||
gap: $space-md;
|
||||
}
|
||||
|
||||
.skeleton-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
gap: $space-xs;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
text-align: center;
|
||||
padding: 32rpx 0;
|
||||
padding: $space-lg 0;
|
||||
}
|
||||
|
||||
.loading-text { font-size: 24rpx; color: #BFB3B3; }
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.empty-text { font-size: 28rpx; color: #BFB3B3; }
|
||||
.loading-text { font-size: $font-md; color: $text-muted; }
|
||||
|
||||
.state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@include state-box;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
@include state-text;
|
||||
}
|
||||
|
||||
/* 标签筛选条 */
|
||||
.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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -125,7 +127,7 @@ async function fetchMyExpense() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||
loading.value = true
|
||||
try {
|
||||
await budgetStore.fetchBudget(currentMonth)
|
||||
@@ -150,8 +152,18 @@ onShow(async () => {
|
||||
} catch {}
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await budgetStore.fetchBudget(currentMonth)
|
||||
syncBudgetData()
|
||||
fetchMyExpense()
|
||||
} catch {}
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
function selectPreset(val: number) {
|
||||
amountStr.value = String(val)
|
||||
amountEditorRef.value?.focus()
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
@@ -180,46 +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 {
|
||||
@@ -227,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 {
|
||||
@@ -247,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;
|
||||
}
|
||||
.input-cursor {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</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>
|
||||
<EditModal
|
||||
v-model:visible="showAddModal"
|
||||
title="添加自定义分类"
|
||||
:fields="catFields"
|
||||
:modelValue="addModel"
|
||||
@confirm="onAdd"
|
||||
/>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
<EditModal
|
||||
v-model:visible="showEditModal"
|
||||
title="编辑分类"
|
||||
:fields="catFields"
|
||||
:modelValue="editModel"
|
||||
@confirm="onEditConfirm"
|
||||
/>
|
||||
|
||||
<!-- 迁移弹窗 -->
|
||||
<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; }
|
||||
|
||||
343
client/src/pages/data-import/index.vue
Normal file
343
client/src/pages/data-import/index.vue
Normal 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">支持 CSV、JSON 格式,文件需小于 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>
|
||||
485
client/src/pages/feedback/index.vue
Normal file
485
client/src/pages/feedback/index.vue
Normal 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>
|
||||
@@ -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">
|
||||
<Icon name="user" :size="48" color="#BFB3B3" />
|
||||
<view class="empty-icon-wrap">
|
||||
<Icon name="user" :size="48" color="#BFB3B3" />
|
||||
</view>
|
||||
<text class="empty-text">还没有加入任何群组</text>
|
||||
<text class="empty-hint">创建或加入一个群组,和家人朋友一起记账</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="group-list">
|
||||
@@ -75,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>
|
||||
@@ -135,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)
|
||||
@@ -158,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
|
||||
})
|
||||
@@ -168,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()
|
||||
}
|
||||
@@ -179,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()
|
||||
@@ -325,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 {
|
||||
@@ -386,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;
|
||||
@@ -505,24 +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;
|
||||
border-radius: $radius-xs;
|
||||
}
|
||||
|
||||
.role-badge-text {
|
||||
font-size: 20rpx;
|
||||
font-size: $font-xs;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group-meta {
|
||||
font-size: 24rpx;
|
||||
color: #BFB3B3;
|
||||
font-size: $font-md;
|
||||
color: $text-muted;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
@@ -532,19 +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;
|
||||
}
|
||||
|
||||
@@ -553,85 +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;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx 40rpx 24rpx;
|
||||
border-bottom: 2rpx solid #F0E0D6;
|
||||
padding: $space-lg $space-xl $space-md;
|
||||
border-bottom: 2rpx solid $border;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-size: $font-xl;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
@@ -641,14 +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;
|
||||
}
|
||||
|
||||
@@ -661,19 +717,17 @@ function handleDissolve(group: Group) {
|
||||
|
||||
.member-list {
|
||||
max-height: 60vh;
|
||||
padding: 16rpx 40rpx 40rpx;
|
||||
box-sizing: border-box;
|
||||
padding: $space-sm $space-xl $space-xl;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
gap: 20rpx;
|
||||
padding: $space-lg 0;
|
||||
gap: $space-lg;
|
||||
border-bottom: 2rpx solid #F8F0EB;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
@@ -682,7 +736,7 @@ function handleDissolve(group: Group) {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: #F0E0D6;
|
||||
background: $border;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -692,9 +746,9 @@ function handleDissolve(group: Group) {
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 28rpx;
|
||||
font-size: $font-lg;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -706,22 +760,22 @@ function handleDissolve(group: Group) {
|
||||
}
|
||||
|
||||
.member-role-text {
|
||||
font-size: 22rpx;
|
||||
font-size: $font-sm;
|
||||
}
|
||||
|
||||
.member-remove {
|
||||
padding: 12rpx 24rpx;
|
||||
background: #FFE8E8;
|
||||
border-radius: 16rpx;
|
||||
padding: 12rpx $space-md;
|
||||
background: $danger-light;
|
||||
border-radius: $radius-md;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.member-remove-text {
|
||||
font-size: 24rpx;
|
||||
font-size: $font-md;
|
||||
font-weight: 500;
|
||||
color: #FF6B6B;
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -108,7 +108,17 @@
|
||||
<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>
|
||||
@@ -121,7 +131,7 @@
|
||||
<text class="urgent-title">{{ urgentNotice.title }}</text>
|
||||
</view>
|
||||
<scroll-view class="urgent-body" scroll-y>
|
||||
<image v-if="urgentNotice.image_url" class="urgent-image" :src="urgentNotice.image_url" mode="widthFix" />
|
||||
<image v-if="urgentNotice.image_url" class="urgent-image" :src="getNotificationImageUrl(urgentNotice.image_url)" mode="widthFix" />
|
||||
<text class="urgent-content">{{ urgentNotice.content }}</text>
|
||||
<view v-if="urgentNotice.link_url" class="urgent-link" @tap="openLink(urgentNotice.link_url)">
|
||||
<text class="urgent-link-text">查看详情</text>
|
||||
@@ -146,6 +156,9 @@ import { useGroupStore } from '@/stores/group'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { getOverview } from '@/api/stats'
|
||||
import { syncRecurring } from '@/api/recurring'
|
||||
import type { Transaction } from '@/api/transaction'
|
||||
import { getNotificationImageUrl } from '@/api/notification'
|
||||
import { formatAmount, formatAmountRaw } from '@/utils/format'
|
||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
|
||||
@@ -164,9 +177,10 @@ 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)
|
||||
@@ -202,27 +216,41 @@ function openLink(url: string) {
|
||||
}
|
||||
|
||||
async function loadData(silent = false) {
|
||||
await waitForReady()
|
||||
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)
|
||||
txStore.fetchTransactions({ page: 1 })
|
||||
])
|
||||
recentTx.value = txStore.transactions.slice(0, 5)
|
||||
|
||||
// 非关键数据:失败不影响页面显示
|
||||
// 第二级:次要数据(异步,不阻塞首屏)
|
||||
Promise.all([
|
||||
budgetStore.fetchBudget(),
|
||||
fetchTodayData()
|
||||
]).catch(() => {})
|
||||
|
||||
// 第三级:非关键数据
|
||||
Promise.allSettled([
|
||||
userStore.fetchUserInfo(),
|
||||
notifStore.fetchUnreadCount(),
|
||||
notifStore.fetchUrgentNotifications()
|
||||
])
|
||||
notifStore.fetchUrgentNotifications(),
|
||||
syncRecurring().catch(() => {})
|
||||
]).catch(() => {})
|
||||
} catch (e) {
|
||||
console.error('Load error:', e)
|
||||
if (!silent) loadError.value = true
|
||||
@@ -231,8 +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
|
||||
@@ -268,7 +298,7 @@ function goNotifications() {
|
||||
|
||||
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}` })
|
||||
@@ -277,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;
|
||||
}
|
||||
|
||||
@@ -322,71 +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: 8rpx;
|
||||
right: 8rpx;
|
||||
top: $space-xs;
|
||||
right: $space-xs;
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
background: #FF6B6B;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 $space-xs;
|
||||
background: $danger;
|
||||
border-radius: $radius-full;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.bell-badge-text {
|
||||
font-size: 18rpx;
|
||||
font-size: $font-xs;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
color: $surface;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
margin: 0 40rpx;
|
||||
padding: 48rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 40rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08),
|
||||
inset -2rpx -2rpx 6rpx rgba(45, 27, 27, 0.04);
|
||||
margin: 0 $space-xl;
|
||||
padding: $space-2xl;
|
||||
@include card($radius-2xl);
|
||||
box-shadow: $shadow-clay;
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -394,195 +418,172 @@ 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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
padding: 80rpx 0;
|
||||
@include state-box;
|
||||
}
|
||||
|
||||
.state-text {
|
||||
font-size: 28rpx;
|
||||
color: #BFB3B3;
|
||||
@include state-text;
|
||||
}
|
||||
|
||||
/* 强提醒公告弹窗 */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@include modal-mask-high;
|
||||
}
|
||||
|
||||
.urgent-modal {
|
||||
width: 80%;
|
||||
max-height: 70vh;
|
||||
background: #FFFFFF;
|
||||
border-radius: 32rpx;
|
||||
background: $surface;
|
||||
border-radius: $radius-2xl;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -591,52 +592,52 @@ function goBills() {
|
||||
.urgent-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 32rpx 32rpx 16rpx;
|
||||
gap: $space-sm;
|
||||
padding: $space-xl $space-lg $space-md;
|
||||
}
|
||||
|
||||
.urgent-title {
|
||||
font-size: 32rpx;
|
||||
font-size: $font-xl;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.urgent-body {
|
||||
flex: 1;
|
||||
padding: 0 32rpx 24rpx;
|
||||
padding: 0 $space-lg $space-lg;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.urgent-image {
|
||||
width: 100%;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
border-radius: $radius-md;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
.urgent-content {
|
||||
font-size: 28rpx;
|
||||
color: #2D1B1B;
|
||||
font-size: $font-lg;
|
||||
color: $text;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.urgent-link {
|
||||
margin-top: 16rpx;
|
||||
padding: 12rpx 0;
|
||||
margin-top: $space-sm;
|
||||
padding: $space-xs 0;
|
||||
}
|
||||
|
||||
.urgent-link-text {
|
||||
font-size: 28rpx;
|
||||
color: #FF8C69;
|
||||
font-size: $font-lg;
|
||||
color: $primary;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.urgent-footer {
|
||||
padding: 24rpx 32rpx;
|
||||
border-top: 2rpx solid #F0E0D6;
|
||||
padding: $space-lg;
|
||||
border-top: 2rpx solid $border;
|
||||
text-align: center;
|
||||
|
||||
&:active { background: #FFF8F0; }
|
||||
&:active { background: $surface-warm; }
|
||||
}
|
||||
|
||||
.urgent-btn-text {
|
||||
@@ -644,4 +645,41 @@ function goBills() {
|
||||
font-weight: 600;
|
||||
color: #FF8C69;
|
||||
}
|
||||
|
||||
/* 网络异常全屏提示 */
|
||||
.network-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx $space-xl;
|
||||
gap: $space-md;
|
||||
}
|
||||
|
||||
.network-error-title {
|
||||
font-size: $font-2xl;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
margin-top: $space-lg;
|
||||
}
|
||||
|
||||
.network-error-desc {
|
||||
font-size: $font-lg;
|
||||
color: $text-sec;
|
||||
}
|
||||
|
||||
.network-error-btn {
|
||||
margin-top: $space-lg;
|
||||
padding: $space-md $space-2xl;
|
||||
background: $primary;
|
||||
border-radius: $radius-xl;
|
||||
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
|
||||
.network-error-btn-text {
|
||||
font-size: $font-lg;
|
||||
font-weight: 600;
|
||||
color: $surface;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,13 +23,23 @@
|
||||
</view>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<view v-if="loading && list.length === 0" class="loading-box">
|
||||
<text class="loading-text">加载中...</text>
|
||||
<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">
|
||||
<Icon name="bell" :size="48" color="#BFB3B3" />
|
||||
<view class="empty-icon-wrap">
|
||||
<Icon name="bell" :size="48" color="#BFB3B3" />
|
||||
</view>
|
||||
<text class="empty-text">暂无通知</text>
|
||||
<text class="empty-hint">新的通知会在这里显示</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="notification-list">
|
||||
@@ -39,24 +49,15 @@
|
||||
</view>
|
||||
<view class="notif-content">
|
||||
<view class="notif-title-row">
|
||||
<text v-if="item.is_pinned" class="pin-badge">📌</text>
|
||||
<text v-if="item.is_pinned" class="pin-badge">置顶</text>
|
||||
<text class="notif-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
|
||||
<image v-if="item.image_url" class="notif-image" :src="item.image_url" mode="widthFix" />
|
||||
<image v-if="item.image_url" class="notif-image" :src="getNotificationImageUrl(item.image_url)" mode="widthFix" />
|
||||
<view class="notif-meta">
|
||||
<text class="notif-time">{{ formatTime(item.created_at) }}</text>
|
||||
<text v-if="item.link_url" class="notif-link" @tap.stop="openLink(item.link_url)">查看详情</text>
|
||||
</view>
|
||||
<!-- 管理员操作 -->
|
||||
<view v-if="isAdmin && item.type === 'system'" class="admin-actions">
|
||||
<view class="admin-btn" @tap.stop="handleTogglePin(item)">
|
||||
<text class="admin-btn-text">{{ item.is_pinned ? '取消置顶' : '置顶' }}</text>
|
||||
</view>
|
||||
<view class="admin-btn danger" @tap.stop="handleDeleteNotif(item)">
|
||||
<text class="admin-btn-text danger-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!item.is_read" class="unread-dot"></view>
|
||||
</view>
|
||||
@@ -65,23 +66,41 @@
|
||||
<text class="no-more-text">没有更多了</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 通知详情弹窗 -->
|
||||
<view class="modal-mask" v-if="detailItem" @tap="detailItem = null">
|
||||
<view class="detail-modal" @tap.stop>
|
||||
<view class="detail-header">
|
||||
<text class="detail-title">{{ detailItem.title }}</text>
|
||||
<view class="detail-close" @tap="detailItem = null">
|
||||
<text class="detail-close-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view class="detail-body" scroll-y>
|
||||
<image v-if="detailItem.image_url" class="detail-image" :src="getNotificationImageUrl(detailItem.image_url)" mode="widthFix" />
|
||||
<text class="detail-content" v-if="detailItem.content">{{ detailItem.content }}</text>
|
||||
<view class="detail-meta">
|
||||
<text class="detail-time">{{ formatTime(detailItem.created_at) }}</text>
|
||||
<text v-if="detailItem.link_url" class="detail-link" @tap="openLink(detailItem.link_url)">查看详情</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onShow, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { useNotificationStore } from '@/stores/notification'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getNotifications, markRead, markAllRead, deleteNotification, updateNotification } from '@/api/notification'
|
||||
import { getNotifications, markRead, markAllRead, getNotificationImageUrl } from '@/api/notification'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||
import type { Notification } from '@/api/notification'
|
||||
|
||||
const notifStore = useNotificationStore()
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.userInfo?.role === 'admin')
|
||||
|
||||
const tabs = [
|
||||
{ key: 'all', label: '全部' },
|
||||
@@ -95,17 +114,25 @@ const list = ref<Notification[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const detailItem = ref<Notification | null>(null)
|
||||
const initialLoaded = ref(false)
|
||||
let loadSeq = 0
|
||||
|
||||
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
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) {
|
||||
@@ -171,6 +198,7 @@ function formatTime(dateStr: string) {
|
||||
}
|
||||
|
||||
async function handleTap(item: Notification) {
|
||||
// 标记已读
|
||||
if (!item.is_read) {
|
||||
try {
|
||||
await markRead(item.id)
|
||||
@@ -180,6 +208,8 @@ async function handleTap(item: Notification) {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
// 显示详情
|
||||
detailItem.value = item
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
@@ -205,40 +235,15 @@ function openLink(url: string) {
|
||||
// #endif
|
||||
}
|
||||
|
||||
/** 管理员:切换置顶状态 */
|
||||
async function handleTogglePin(item: Notification) {
|
||||
try {
|
||||
await updateNotification(item.id, { is_pinned: !item.is_pinned })
|
||||
item.is_pinned = item.is_pinned ? 0 : 1
|
||||
uni.showToast({ title: item.is_pinned ? '已置顶' : '已取消置顶', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理员:删除公告 */
|
||||
function handleDeleteNotif(item: Notification) {
|
||||
uni.showModal({
|
||||
title: '删除公告',
|
||||
content: `确定删除「${item.title}」?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await deleteNotification(item.id)
|
||||
list.value = list.value.filter(n => n.id !== item.id)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await loadNotifications(true)
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (noMore.value || loading.value) return
|
||||
page.value++
|
||||
@@ -247,107 +252,124 @@ onReachBottom(() => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #FFF8F0;
|
||||
padding: 0 0 180rpx;
|
||||
@include page-base;
|
||||
}
|
||||
|
||||
.header-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #FFF8F0;
|
||||
@include sticky-header;
|
||||
}
|
||||
|
||||
.status-bar { background: #FFF8F0; }
|
||||
.status-bar { @include status-bar; }
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 40rpx 24rpx;
|
||||
padding: $space-sm $space-xl $space-md;
|
||||
}
|
||||
|
||||
.nav-back {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.6; }
|
||||
@include nav-back;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-size: $font-2xl;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.nav-action {
|
||||
padding: 8rpx 16rpx;
|
||||
padding: $space-xs $space-sm;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.nav-action-text {
|
||||
font-size: 26rpx;
|
||||
color: #FF8C69;
|
||||
font-size: $font-base;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.tabs-wrap { display: flex; justify-content: center; margin: 0 0 24rpx; }
|
||||
.tabs-wrap { display: flex; justify-content: center; margin: 0 0 $space-md; }
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
background: #FFF0E6;
|
||||
border-radius: 24rpx;
|
||||
padding: 8rpx;
|
||||
background: $surface-warm;
|
||||
border-radius: $space-md;
|
||||
padding: $space-xs;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12rpx 32rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 26rpx;
|
||||
color: #8B7E7E;
|
||||
transition: all 0.2s;
|
||||
padding: 12rpx $space-lg;
|
||||
border-radius: $radius-lg;
|
||||
font-size: $font-base;
|
||||
color: $text-sec;
|
||||
transition: all $transition-normal;
|
||||
|
||||
&.active {
|
||||
background: #FFFFFF;
|
||||
color: #FF8C69;
|
||||
background: $surface;
|
||||
color: $primary;
|
||||
font-weight: 600;
|
||||
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-box, .empty-box {
|
||||
// 骨架屏
|
||||
.skeleton-list { padding: 0 $space-xl; }
|
||||
.skeleton-notif {
|
||||
display: flex; align-items: flex-start; gap: $space-lg;
|
||||
padding: $space-lg; margin-bottom: $space-sm;
|
||||
background: $surface; border-radius: $radius-xl;
|
||||
border: 2rpx solid $border;
|
||||
}
|
||||
.skeleton-info {
|
||||
flex: 1; display: flex; flex-direction: column; gap: $space-xs;
|
||||
}
|
||||
|
||||
// 空状态
|
||||
.empty-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
gap: $space-sm;
|
||||
padding: 120rpx 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;
|
||||
}
|
||||
|
||||
.notification-list { padding: 0 40rpx; }
|
||||
.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: 20rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 28rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
gap: $space-lg;
|
||||
padding: $font-lg $space-lg;
|
||||
margin-bottom: $space-sm;
|
||||
background: $surface;
|
||||
border-radius: $radius-xl;
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-md;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&.unread { border-left: 6rpx solid #FF8C69; }
|
||||
&.unread { border-left: 6rpx solid $primary; }
|
||||
}
|
||||
|
||||
.notif-icon {
|
||||
@@ -365,25 +387,30 @@ onReachBottom(() => {
|
||||
.notif-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
gap: $space-xs;
|
||||
}
|
||||
|
||||
.pin-badge {
|
||||
font-size: 24rpx;
|
||||
font-size: $font-xs;
|
||||
color: $primary;
|
||||
background: #FFE8E0;
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: $radius-xs;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notif-title {
|
||||
font-size: 28rpx;
|
||||
font-size: $font-lg;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.notif-body {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
font-size: $font-md;
|
||||
color: $text-sec;
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
margin-top: $space-xs;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -391,70 +418,137 @@ onReachBottom(() => {
|
||||
|
||||
.notif-image {
|
||||
width: 100%;
|
||||
border-radius: 12rpx;
|
||||
margin-top: 12rpx;
|
||||
border-radius: $radius-md;
|
||||
margin-top: $space-sm;
|
||||
}
|
||||
|
||||
.notif-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8rpx;
|
||||
margin-top: $space-xs;
|
||||
}
|
||||
|
||||
.notif-time {
|
||||
font-size: 22rpx;
|
||||
color: #BFB3B3;
|
||||
font-size: $font-sm;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
width: $space-sm;
|
||||
height: $space-sm;
|
||||
border-radius: 50%;
|
||||
background: #FF8C69;
|
||||
background: $primary;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8rpx;
|
||||
margin-top: $space-xs;
|
||||
}
|
||||
|
||||
.notif-link {
|
||||
font-size: 22rpx;
|
||||
color: #FF8C69;
|
||||
font-size: $font-sm;
|
||||
color: $primary;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pinned {
|
||||
border-color: #FF8C69;
|
||||
border-color: $primary;
|
||||
background: #FFFAF7;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
padding: 32rpx 0;
|
||||
padding: $space-lg 0;
|
||||
}
|
||||
|
||||
.no-more-text {
|
||||
font-size: 24rpx;
|
||||
color: #BFB3B3;
|
||||
font-size: $font-md;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
/* 通知详情弹窗 */
|
||||
.modal-mask {
|
||||
@include modal-mask;
|
||||
}
|
||||
|
||||
.detail-modal {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
background: $surface;
|
||||
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 12rpx;
|
||||
flex-direction: column;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
padding: 8rpx 16rpx;
|
||||
background: #FFF0E6;
|
||||
border-radius: 12rpx;
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $space-lg $space-xl $space-md;
|
||||
border-bottom: 2rpx solid $border;
|
||||
}
|
||||
|
||||
&.danger { background: #FFE8E8; }
|
||||
.detail-title {
|
||||
flex: 1;
|
||||
font-size: $font-xl;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.detail-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
@include flex-center;
|
||||
border-radius: 50%;
|
||||
background: $border;
|
||||
margin-left: $space-md;
|
||||
&:active { background: darken($border, 5%); }
|
||||
}
|
||||
|
||||
.detail-close-text {
|
||||
font-size: $font-2xl;
|
||||
color: $text-sec;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
flex: 1;
|
||||
padding: $space-md $space-xl $space-xl;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
width: 100%;
|
||||
border-radius: $radius-md;
|
||||
margin-bottom: $space-md;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
font-size: $font-lg;
|
||||
color: $text;
|
||||
line-height: 1.8;
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: $space-lg;
|
||||
padding-top: $space-md;
|
||||
border-top: 2rpx solid $border;
|
||||
}
|
||||
|
||||
.detail-time {
|
||||
font-size: $font-sm;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
font-size: $font-sm;
|
||||
color: $primary;
|
||||
text-decoration: underline;
|
||||
&:active { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.admin-btn-text {
|
||||
font-size: 22rpx;
|
||||
color: #FF8C69;
|
||||
&.danger-text { color: #FF6B6B; }
|
||||
}
|
||||
</style>
|
||||
|
||||
155
client/src/pages/privacy/index.vue
Normal file
155
client/src/pages/privacy/index.vue
Normal 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>
|
||||
@@ -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">• 头像支持 JPG、PNG 格式,最大 2MB</text>
|
||||
<text class="tips-text">• 签名最长 100 个字符</text>
|
||||
<text class="tips-text">• 头像支持 JPG、PNG、WebP 格式,最大 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>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</view>
|
||||
<view class="p-info">
|
||||
<text class="p-name">{{ userStore.nickname }}</text>
|
||||
<text class="p-tagline">记账小能手</text>
|
||||
<text class="p-tagline">{{ userStore.slogan }}</text>
|
||||
<text class="p-streak">累计 {{ txStore.total }} 笔记录</text>
|
||||
</view>
|
||||
<Icon name="chevronRight" :size="28" color="rgba(255,255,255,0.6)" />
|
||||
@@ -51,17 +51,37 @@
|
||||
<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>
|
||||
|
||||
<!-- 管理员入口 -->
|
||||
@@ -71,21 +91,89 @@
|
||||
<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>
|
||||
@@ -127,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'
|
||||
@@ -144,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)
|
||||
@@ -152,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([
|
||||
@@ -185,6 +306,20 @@ onShow(async () => {
|
||||
} catch {}
|
||||
})
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
statsStore.fetchOverview(),
|
||||
budgetStore.fetchBudget(),
|
||||
txStore.fetchTransactions({ page: 1, pageSize: 1 }),
|
||||
groupStore.fetchGroups(),
|
||||
userStore.fetchUserInfo(),
|
||||
configStore.fetchConfig()
|
||||
])
|
||||
} catch {}
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
function goBudget() {
|
||||
uni.navigateTo({ url: '/pages/budget/index' })
|
||||
}
|
||||
@@ -193,6 +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' })
|
||||
}
|
||||
@@ -290,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 {
|
||||
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 {
|
||||
// 分页获取全部数据(服务端 pageSize 上限 100)
|
||||
const allList: any[] = []
|
||||
// 分页获取筛选后的数据
|
||||
const allList: Transaction[] = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
const baseParams: Record<string, any> = {
|
||||
pageSize: 100,
|
||||
startDate: exportStartDate.value,
|
||||
endDate: exportEndDate.value,
|
||||
group_id: groupStore.currentGroupId
|
||||
}
|
||||
if (exportType.value !== 'all') baseParams.type = exportType.value
|
||||
|
||||
do {
|
||||
const data = await getTransactions({ page, pageSize: 100, group_id: groupStore.currentGroupId })
|
||||
const data = await getTransactions({ page, ...baseParams } as any)
|
||||
allList.push(...data.list)
|
||||
total = data.total
|
||||
page++
|
||||
const progress = Math.min(100, Math.round((allList.length / total) * 100))
|
||||
uni.showLoading({ title: `导出中 ${progress}%` })
|
||||
} while (allList.length < total)
|
||||
|
||||
if (allList.length === 0) {
|
||||
@@ -310,88 +524,106 @@ async function exportData() {
|
||||
return
|
||||
}
|
||||
|
||||
function csvEscape(val: string): string {
|
||||
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
||||
return '"' + val.replace(/"/g, '""') + '"'
|
||||
if (exportFormat.value === 'json') {
|
||||
const json = JSON.stringify(allList.map(t => ({
|
||||
date: t.date,
|
||||
type: t.type === 'expense' ? '支出' : '收入',
|
||||
category: t.category_name || '未分类',
|
||||
amount: (t.amount / 100).toFixed(2),
|
||||
note: t.note || ''
|
||||
})), null, 2)
|
||||
|
||||
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.json`
|
||||
saveFile(json, fileName, 'application/json')
|
||||
} else {
|
||||
function csvEscape(val: string): string {
|
||||
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
||||
return '"' + val.replace(/"/g, '""') + '"'
|
||||
}
|
||||
return val
|
||||
}
|
||||
return val
|
||||
const header = '日期,类型,分类,金额(元),备注\n'
|
||||
const rows = allList.map(t => {
|
||||
const amount = (t.amount / 100).toFixed(2)
|
||||
const type = t.type === 'expense' ? '支出' : '收入'
|
||||
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
|
||||
}).join('\n')
|
||||
|
||||
const csv = '' + header + rows
|
||||
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.csv`
|
||||
saveFile(csv, fileName, 'text/csv')
|
||||
}
|
||||
const header = '日期,类型,分类,金额(元),备注\n'
|
||||
const rows = allList.map(t => {
|
||||
const amount = (t.amount / 100).toFixed(2)
|
||||
const type = t.type === 'expense' ? '支出' : '收入'
|
||||
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
|
||||
}).join('\n')
|
||||
|
||||
const csv = '' + header + rows
|
||||
|
||||
// #ifdef H5
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `小菜记账_${getLocalDateStr()}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '导出成功', icon: 'success' })
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
const fs = uni.getFileSystemManager()
|
||||
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${getLocalDateStr()}.csv`
|
||||
fs.writeFileSync(filePath, csv, 'utf-8')
|
||||
uni.hideLoading()
|
||||
uni.openDocument({
|
||||
filePath,
|
||||
fileType: 'csv',
|
||||
success: () => {},
|
||||
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
|
||||
})
|
||||
// #endif
|
||||
} catch {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '导出失败', icon: 'none' })
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
showExportPanel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function saveFile(content: string, fileName: string, mimeType: string) {
|
||||
// #ifdef H5
|
||||
const blob = new Blob([content], { type: `${mimeType};charset=utf-8;` })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '导出成功', icon: 'success' })
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
const fs = uni.getFileSystemManager()
|
||||
const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`
|
||||
fs.writeFileSync(filePath, content, 'utf-8')
|
||||
uni.hideLoading()
|
||||
const ext = mimeType === 'application/json' ? 'json' : 'csv'
|
||||
uni.openDocument({
|
||||
filePath,
|
||||
fileType: ext,
|
||||
success: () => {},
|
||||
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins.scss';
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #FFF8F0;
|
||||
padding: 0 0 180rpx;
|
||||
@include page-base;
|
||||
}
|
||||
|
||||
.header-fixed {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #FFF8F0;
|
||||
@include sticky-header;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: #FFF8F0;
|
||||
@include status-bar;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 36rpx;
|
||||
font-size: $font-2xl;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
padding: 16rpx 0 24rpx;
|
||||
color: $text;
|
||||
padding: $space-sm 0 $space-md;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
margin: 0 40rpx;
|
||||
padding: 48rpx;
|
||||
background: linear-gradient(135deg, #FF8C69, #FFB89A);
|
||||
border-radius: 40rpx;
|
||||
margin: 0 $space-xl;
|
||||
padding: $space-2xl;
|
||||
background: linear-gradient(135deg, $primary, #FFB89A);
|
||||
border-radius: $radius-2xl;
|
||||
box-shadow: 8rpx 8rpx 24rpx rgba(45, 27, 27, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32rpx;
|
||||
gap: $space-lg;
|
||||
|
||||
&:active { opacity: 0.85; }
|
||||
}
|
||||
@@ -408,72 +640,65 @@ async function exportData() {
|
||||
}
|
||||
|
||||
.p-name {
|
||||
font-size: 40rpx;
|
||||
font-size: $font-3xl;
|
||||
font-weight: 600;
|
||||
color: #FFFFFF;
|
||||
color: $surface;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.p-tagline {
|
||||
font-size: 28rpx;
|
||||
font-size: $font-lg;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.p-streak {
|
||||
font-size: 24rpx;
|
||||
font-size: $font-md;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
margin-top: $space-sm;
|
||||
}
|
||||
|
||||
.quick-stats {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 0 40rpx;
|
||||
margin-top: 32rpx;
|
||||
gap: $space-md;
|
||||
padding: 0 $space-xl;
|
||||
margin-top: $space-lg;
|
||||
}
|
||||
|
||||
.qs-card {
|
||||
flex: 1;
|
||||
padding: 32rpx 24rpx;
|
||||
background: #FFFFFF;
|
||||
padding: $space-lg $space-md;
|
||||
background: $surface;
|
||||
border-radius: 32rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-md;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qs-label {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
font-size: $font-md;
|
||||
color: $text-sec;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
margin-bottom: $space-xs;
|
||||
}
|
||||
|
||||
.qs-value {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 32rpx;
|
||||
font-size: $font-xl;
|
||||
font-weight: 600;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
|
||||
&.income { color: #7BC67E; }
|
||||
&.income { color: $success; }
|
||||
}
|
||||
|
||||
.qs-skeleton {
|
||||
width: 160rpx;
|
||||
height: 40rpx;
|
||||
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
|
||||
background-size: 200% 100%;
|
||||
border-radius: 8rpx;
|
||||
@include shimmer;
|
||||
border-radius: $radius-xs;
|
||||
margin: 0 auto;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -483,98 +708,92 @@ async function exportData() {
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
margin: 40rpx 40rpx 0;
|
||||
background: #FFFFFF;
|
||||
border-radius: 40rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
margin: $space-xl $space-xl 0;
|
||||
background: $surface;
|
||||
border-radius: $radius-2xl;
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-md;
|
||||
overflow: hidden;
|
||||
|
||||
&.mt { margin-top: 32rpx; }
|
||||
&.mt { margin-top: $space-lg; }
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
height: 112rpx;
|
||||
padding: 0 40rpx;
|
||||
padding: 0 $space-xl;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1rpx solid #F0E0D6;
|
||||
border-bottom: 1rpx solid $border;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
&:active { background: #FFF8F0; }
|
||||
&:active { background: $bg; }
|
||||
}
|
||||
|
||||
.mi-label { flex: 1; font-size: 28rpx; font-weight: 500; color: #2D1B1B; }
|
||||
.mi-extra { font-size: 28rpx; color: #FF8C69; font-weight: 600; margin-right: 16rpx; }
|
||||
.mi-label { flex: 1; font-size: $font-lg; font-weight: 500; color: $text; }
|
||||
.mi-extra { font-size: $font-lg; color: $primary; font-weight: 600; margin-right: $space-sm; }
|
||||
|
||||
.admin-entry {
|
||||
gap: 16rpx;
|
||||
.mi-label { color: #FF8C69; font-weight: 600; }
|
||||
gap: $space-sm;
|
||||
.mi-label { color: $primary; font-weight: 600; }
|
||||
}
|
||||
|
||||
.about-title {
|
||||
font-size: 40rpx;
|
||||
font-size: $font-3xl;
|
||||
font-weight: 600;
|
||||
color: #FF8C69;
|
||||
color: $primary;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.about-version {
|
||||
font-size: 24rpx;
|
||||
color: #BFB3B3;
|
||||
font-size: $font-md;
|
||||
color: $text-muted;
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin: 8rpx 0 24rpx;
|
||||
margin: $space-xs 0 $space-md;
|
||||
}
|
||||
|
||||
.about-desc {
|
||||
font-size: 28rpx;
|
||||
color: #8B7E7E;
|
||||
font-size: $font-lg;
|
||||
color: $text-sec;
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-bottom: 8rpx;
|
||||
margin-bottom: $space-xs;
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
@include modal-mask-high;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 600rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 32rpx;
|
||||
padding: 48rpx;
|
||||
background: $surface;
|
||||
border-radius: $space-lg;
|
||||
padding: $space-2xl;
|
||||
}
|
||||
|
||||
.modal-btns {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
gap: $space-md;
|
||||
}
|
||||
|
||||
.modal-btns-top {
|
||||
margin-top: 40rpx;
|
||||
margin-top: $space-xl;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 24rpx;
|
||||
border-radius: $space-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-size: $font-lg;
|
||||
font-weight: 600;
|
||||
|
||||
&.confirm {
|
||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, $primary, #E67355);
|
||||
color: $surface;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,17 +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 {
|
||||
@@ -605,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;
|
||||
}
|
||||
|
||||
@@ -621,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 {
|
||||
@@ -657,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>
|
||||
|
||||
545
client/src/pages/recurring/index.vue
Normal file
545
client/src/pages/recurring/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -95,7 +103,7 @@
|
||||
/>
|
||||
<view class="trend-list">
|
||||
<view v-for="point in trendData" :key="point.date" class="trend-item">
|
||||
<text class="trend-date">{{ point.date.slice(8) }}日</text>
|
||||
<text class="trend-date">{{ point.label || (point.date.slice(8) + '日') }}</text>
|
||||
<view class="trend-bar-bg">
|
||||
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
|
||||
</view>
|
||||
@@ -120,9 +128,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { onShow, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { useStatsStore } from '@/stores/stats'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { getTransactions } from '@/api/transaction'
|
||||
import { getOverview } from '@/api/stats'
|
||||
@@ -134,9 +143,11 @@ 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 period = ref<'week' | 'month' | 'year'>('month')
|
||||
const { overview, categoryStats, trendData } = storeToRefs(statsStore)
|
||||
const loading = ref(true)
|
||||
const loadError = ref(false)
|
||||
@@ -174,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)
|
||||
@@ -227,7 +238,7 @@ const monthCompareText = computed(() => {
|
||||
const initialLoaded = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await waitForReady()
|
||||
try { await waitForReady() } catch { /* 超时,继续加载 */ }
|
||||
loadData().finally(() => { initialLoaded.value = true })
|
||||
})
|
||||
|
||||
@@ -235,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()
|
||||
])
|
||||
@@ -261,6 +274,19 @@ 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)
|
||||
@@ -295,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')}`
|
||||
@@ -306,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
|
||||
@@ -313,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; }
|
||||
|
||||
@@ -361,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 {
|
||||
@@ -407,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 {
|
||||
@@ -479,74 +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 {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
font-size: $font-md;
|
||||
color: $text-sec;
|
||||
}
|
||||
|
||||
.trend-bar-bg {
|
||||
flex: 1;
|
||||
height: 12rpx;
|
||||
border-radius: 6rpx;
|
||||
background: #F0E0D6;
|
||||
background: $border;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.trend-bar {
|
||||
height: 100%;
|
||||
border-radius: 6rpx;
|
||||
background: linear-gradient(90deg, #FF8C69, #FFB89A);
|
||||
background: linear-gradient(90deg, $primary, #FFB89A);
|
||||
transition: width 0.6s ease-out;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.trend-amount {
|
||||
width: 120rpx;
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 24rpx;
|
||||
font-size: $font-md;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
}
|
||||
</style>
|
||||
|
||||
267
client/src/pages/tag-manage/index.vue
Normal file
267
client/src/pages/tag-manage/index.vue
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
categories.value = await api.getCategories()
|
||||
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 result = await api.createCategory(data)
|
||||
await fetchCategories()
|
||||
return result.id
|
||||
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)
|
||||
// 用真实 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 }) {
|
||||
await api.updateCategory(id, data)
|
||||
await fetchCategories()
|
||||
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)
|
||||
} 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[]) {
|
||||
await api.sortCategories(ids)
|
||||
await fetchCategories()
|
||||
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)
|
||||
} 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 }
|
||||
})
|
||||
|
||||
28
client/src/stores/config.ts
Normal file
28
client/src/stores/config.ts
Normal 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 }
|
||||
})
|
||||
@@ -22,27 +22,30 @@ export const useGroupStore = defineStore('group', () => {
|
||||
const isGroupMode = computed(() => currentGroupId.value !== null)
|
||||
|
||||
/** 切换到个人模式 */
|
||||
function switchToPersonal() {
|
||||
async function switchToPersonal() {
|
||||
currentGroupId.value = null
|
||||
uni.removeStorageSync('xc:currentGroupId')
|
||||
refreshAll()
|
||||
await refreshAll()
|
||||
}
|
||||
|
||||
/** 切换到群组模式 */
|
||||
function switchToGroup(groupId: number) {
|
||||
async function switchToGroup(groupId: number) {
|
||||
currentGroupId.value = groupId
|
||||
uni.setStorageSync('xc:currentGroupId', groupId)
|
||||
refreshAll()
|
||||
await refreshAll()
|
||||
}
|
||||
|
||||
/** 切换身份后全局刷新所有数据 */
|
||||
function refreshAll() {
|
||||
/** 切换身份后全局刷新所有数据(无依赖关系的可并行) */
|
||||
async function refreshAll() {
|
||||
const statsStore = useStatsStore()
|
||||
const budgetStore = useBudgetStore()
|
||||
const txStore = useTransactionStore()
|
||||
statsStore.fetchOverview()
|
||||
budgetStore.fetchBudget()
|
||||
txStore.fetchTransactions({ page: 1 })
|
||||
// stats、budget、transactions 互相独立,可以并行
|
||||
await Promise.all([
|
||||
statsStore.fetchOverview(),
|
||||
budgetStore.fetchBudget(),
|
||||
txStore.fetchTransactions({ page: 1 })
|
||||
])
|
||||
}
|
||||
|
||||
/** 获取群组列表 */
|
||||
@@ -55,7 +58,7 @@ export const useGroupStore = defineStore('group', () => {
|
||||
if (currentGroupId.value !== null) {
|
||||
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
|
||||
if (!stillValid) {
|
||||
switchToPersonal()
|
||||
await switchToPersonal()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -83,7 +86,7 @@ export const useGroupStore = defineStore('group', () => {
|
||||
async function leaveGroup(groupId: number) {
|
||||
await api.leaveGroup(groupId)
|
||||
if (currentGroupId.value === groupId) {
|
||||
switchToPersonal()
|
||||
await switchToPersonal()
|
||||
}
|
||||
await fetchGroups()
|
||||
}
|
||||
@@ -92,7 +95,7 @@ export const useGroupStore = defineStore('group', () => {
|
||||
async function deleteGroup(groupId: number) {
|
||||
await api.deleteGroup(groupId)
|
||||
if (currentGroupId.value === groupId) {
|
||||
switchToPersonal()
|
||||
await switchToPersonal()
|
||||
}
|
||||
await fetchGroups()
|
||||
}
|
||||
@@ -127,7 +130,8 @@ export const useGroupStore = defineStore('group', () => {
|
||||
function init() {
|
||||
const saved = uni.getStorageSync('xc:currentGroupId')
|
||||
if (saved) {
|
||||
currentGroupId.value = saved
|
||||
// 确保类型为数字(某些平台可能返回字符串)
|
||||
currentGroupId.value = typeof saved === 'string' ? Number(saved) : saved
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,29 +6,52 @@ import type { Notification } from '@/api/notification'
|
||||
export const useNotificationStore = defineStore('notification', () => {
|
||||
const unreadCount = ref(0)
|
||||
const urgentNotifications = ref<Notification[]>([])
|
||||
/** 上次获取未读计数的时间戳 */
|
||||
const lastCountFetchTime = ref(0)
|
||||
const COUNT_CACHE_TTL = 30 * 1000 // 30 秒
|
||||
|
||||
async function fetchUnreadCount() {
|
||||
const data = await api.getUnreadCount()
|
||||
unreadCount.value = data.count
|
||||
async function fetchUnreadCount(force = false) {
|
||||
if (!force && lastCountFetchTime.value && Date.now() - lastCountFetchTime.value < COUNT_CACHE_TTL) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await api.getUnreadCount()
|
||||
unreadCount.value = data.count
|
||||
lastCountFetchTime.value = Date.now()
|
||||
} catch {
|
||||
// 静默失败,不影响页面
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取未读的强提醒公告(首页弹窗用) */
|
||||
async function fetchUrgentNotifications() {
|
||||
urgentNotifications.value = await api.getPinnedNotifications()
|
||||
try {
|
||||
urgentNotifications.value = await api.getPinnedNotifications()
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(id: number) {
|
||||
await api.markRead(id)
|
||||
if (unreadCount.value > 0) unreadCount.value--
|
||||
// 从强提醒列表中移除
|
||||
urgentNotifications.value = urgentNotifications.value.filter(n => n.id !== id)
|
||||
try {
|
||||
await api.markRead(id)
|
||||
if (unreadCount.value > 0) unreadCount.value--
|
||||
// 从强提醒列表中移除
|
||||
urgentNotifications.value = urgentNotifications.value.filter(n => n.id !== id)
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
await api.markAllRead()
|
||||
unreadCount.value = 0
|
||||
urgentNotifications.value = []
|
||||
try {
|
||||
await api.markAllRead()
|
||||
unreadCount.value = 0
|
||||
urgentNotifications.value = []
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
return { unreadCount, urgentNotifications, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead }
|
||||
return { unreadCount, urgentNotifications, lastCountFetchTime, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead }
|
||||
})
|
||||
|
||||
@@ -36,8 +36,8 @@ export const useStatsStore = defineStore('stats', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCategoryStats(month?: string, type: string = 'expense') {
|
||||
const data = await api.getCategoryStats(month || getCurrentMonth(), type, getGroupId())
|
||||
async function fetchCategoryStats(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
|
||||
const data = await api.getCategoryStats(month || getCurrentMonth(), type, getGroupId(), period)
|
||||
categoryStats.value = Array.isArray(data) ? data.map(item => ({
|
||||
...item,
|
||||
amount: Number(item.amount) || 0,
|
||||
@@ -45,13 +45,44 @@ export const useStatsStore = defineStore('stats', () => {
|
||||
})) : []
|
||||
}
|
||||
|
||||
async function fetchTrend(month?: string, type: string = 'expense') {
|
||||
const data = await api.getTrend(month || getCurrentMonth(), type, getGroupId())
|
||||
async function fetchTrend(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
|
||||
const data = await api.getTrend(month || getCurrentMonth(), type, getGroupId(), period)
|
||||
trendData.value = Array.isArray(data) ? data.map(item => ({
|
||||
...item,
|
||||
amount: Number(item.amount) || 0
|
||||
})) : []
|
||||
}
|
||||
|
||||
return { overview, categoryStats, trendData, fetchOverview, fetchCategoryStats, fetchTrend }
|
||||
/** 聚合接口:一次请求返回 overview + category + trend,减少 HTTP 往返 */
|
||||
async function fetchDashboard(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
|
||||
const data = await api.getDashboard({
|
||||
month: month || getCurrentMonth(),
|
||||
type,
|
||||
period,
|
||||
group_id: getGroupId()
|
||||
})
|
||||
if (data) {
|
||||
const ov = data.overview || EMPTY_OVERVIEW
|
||||
overview.value = {
|
||||
expense: Number(ov.expense) || 0,
|
||||
income: Number(ov.income) || 0,
|
||||
expenseCount: Number(ov.expenseCount) || 0,
|
||||
incomeCount: Number(ov.incomeCount) || 0,
|
||||
count: Number(ov.count) || 0,
|
||||
daily: Number(ov.daily) || 0,
|
||||
dailyIncome: Number(ov.dailyIncome) || 0
|
||||
}
|
||||
categoryStats.value = Array.isArray(data.category) ? data.category.map(item => ({
|
||||
...item,
|
||||
amount: Number(item.amount) || 0,
|
||||
count: Number(item.count) || 0
|
||||
})) : []
|
||||
trendData.value = Array.isArray(data.trend) ? data.trend.map(item => ({
|
||||
...item,
|
||||
amount: Number(item.amount) || 0
|
||||
})) : []
|
||||
}
|
||||
}
|
||||
|
||||
return { overview, categoryStats, trendData, fetchOverview, fetchCategoryStats, fetchTrend, fetchDashboard }
|
||||
})
|
||||
|
||||
84
client/src/stores/tag.ts
Normal file
84
client/src/stores/tag.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from '@/api/tag'
|
||||
|
||||
export const useTagStore = defineStore('tag', () => {
|
||||
const tags = ref<api.Tag[]>([])
|
||||
const loading = ref(false)
|
||||
/** 上次成功获取的时间戳(ms),用于 5 分钟缓存判断 */
|
||||
const lastFetchTime = ref(0)
|
||||
const CACHE_TTL = 5 * 60 * 1000 // 5 分钟
|
||||
|
||||
/** 获取标签列表 */
|
||||
async function fetchTags(force = false) {
|
||||
if (!force && lastFetchTime.value && Date.now() - lastFetchTime.value < CACHE_TTL) {
|
||||
return // 缓存有效,跳过
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.getTags()
|
||||
tags.value = Array.isArray(data) ? data : data.list || []
|
||||
lastFetchTime.value = Date.now()
|
||||
} catch {
|
||||
tags.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 乐观创建标签 */
|
||||
async function addTag(data: { name: string; color: string }) {
|
||||
const tempId = -Date.now()
|
||||
const optimistic: api.Tag = { id: tempId, name: data.name, color: data.color, created_at: new Date().toISOString() }
|
||||
tags.value.push(optimistic)
|
||||
lastFetchTime.value = 0 // 使缓存失效
|
||||
try {
|
||||
const result = await api.createTag(data)
|
||||
// 用真实 ID 替换临时 ID
|
||||
const idx = tags.value.findIndex(t => t.id === tempId)
|
||||
if (idx !== -1) tags.value[idx].id = result.id
|
||||
return result.id
|
||||
} catch (err) {
|
||||
// 回滚
|
||||
tags.value = tags.value.filter(t => t.id !== tempId)
|
||||
uni.showToast({ title: '添加失败', icon: 'none' })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** 乐观更新标签 */
|
||||
async function updateTag(id: number, data: { name?: string; color?: string }) {
|
||||
const idx = tags.value.findIndex(t => t.id === id)
|
||||
if (idx === -1) return
|
||||
const backup = { ...tags.value[idx] }
|
||||
Object.assign(tags.value[idx], data)
|
||||
lastFetchTime.value = 0
|
||||
try {
|
||||
await api.updateTag(id, data)
|
||||
} catch (err) {
|
||||
// 回滚
|
||||
tags.value[idx] = backup
|
||||
uni.showToast({ title: '修改失败', icon: 'none' })
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除标签 */
|
||||
async function deleteTag(id: number) {
|
||||
await api.deleteTag(id)
|
||||
tags.value = tags.value.filter(t => t.id !== id)
|
||||
lastFetchTime.value = 0
|
||||
}
|
||||
|
||||
/** 根据 ID 获取标签 */
|
||||
function getById(id: number) {
|
||||
return tags.value.find(t => t.id === id)
|
||||
}
|
||||
|
||||
/** 获取选中的标签 ID 对应的标签列表 */
|
||||
function getByIds(ids: number[]) {
|
||||
return tags.value.filter(t => ids.includes(t.id))
|
||||
}
|
||||
|
||||
return { tags, loading, lastFetchTime, fetchTags, addTag, updateTag, deleteTag, getById, getByIds }
|
||||
})
|
||||
@@ -8,6 +8,9 @@ export const useTransactionStore = defineStore('transaction', () => {
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
// 待确认删除:{ id: 被删除的交易ID, item: 被删除的交易对象, index: 在列表中的位置, timer: 定时器句柄 }
|
||||
const pendingDelete = ref<{ id: number; item: api.Transaction; index: number; timer: number } | null>(null)
|
||||
|
||||
async function fetchTransactions(params?: api.TransactionParams) {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -65,5 +68,57 @@ export const useTransactionStore = defineStore('transaction', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { transactions, total, loading, fetchTransactions, addTransaction, updateTransaction, deleteTransaction, fetchTransactionById }
|
||||
/** 开始删除(乐观:先从列表移除,3秒内可撤销) */
|
||||
function startDelete(id: number, sourceItem?: api.Transaction, sourceIndex?: number) {
|
||||
// 如果有上一条待确认删除,立即确认(不能同时有两条)
|
||||
if (pendingDelete.value) {
|
||||
confirmDelete()
|
||||
}
|
||||
|
||||
const idx = transactions.value.findIndex(t => t.id === id)
|
||||
const item = idx !== -1 ? transactions.value[idx] : sourceItem
|
||||
if (!item) return false
|
||||
|
||||
if (idx !== -1) {
|
||||
transactions.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
confirmDelete()
|
||||
}, 3000) as unknown as number
|
||||
|
||||
pendingDelete.value = { id, item, index: idx !== -1 ? idx : sourceIndex ?? 0, timer }
|
||||
return true
|
||||
}
|
||||
|
||||
/** 撤销删除(恢复到原位置) */
|
||||
function cancelDelete() {
|
||||
if (!pendingDelete.value) return
|
||||
const { item, index, timer } = pendingDelete.value
|
||||
clearTimeout(timer)
|
||||
// 插回原位置
|
||||
transactions.value.splice(index, 0, item)
|
||||
pendingDelete.value = null
|
||||
}
|
||||
|
||||
/** 确认删除(实际调接口),返回是否删除成功 */
|
||||
async function confirmDelete(): Promise<boolean> {
|
||||
if (!pendingDelete.value) return true
|
||||
const { id, index, timer } = pendingDelete.value
|
||||
const item = pendingDelete.value.item
|
||||
clearTimeout(timer)
|
||||
pendingDelete.value = null
|
||||
|
||||
try {
|
||||
await api.deleteTransaction(id)
|
||||
return true
|
||||
} catch {
|
||||
// 接口失败,恢复
|
||||
transactions.value.splice(index, 0, item)
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { transactions, total, loading, pendingDelete, fetchTransactions, addTransaction, updateTransaction, deleteTransaction, fetchTransactionById, startDelete, cancelDelete, confirmDelete }
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as api from '@/api/user'
|
||||
import { API_BASE } from '@/config'
|
||||
import { getAvatarUrl } from '@/utils/avatar'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const userInfo = ref<api.UserInfo | null>(null)
|
||||
@@ -10,13 +10,13 @@ export const useUserStore = defineStore('user', () => {
|
||||
return userInfo.value?.nickname || uni.getStorageSync('xc:nickname') || '小菜'
|
||||
})
|
||||
|
||||
const slogan = computed(() => {
|
||||
return userInfo.value?.slogan
|
||||
})
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || ''
|
||||
if (!filename) return ''
|
||||
// 已是完整 URL(http/blob)直接返回
|
||||
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
|
||||
// 拼接:API_BASE + /user/avatar/ + 文件名
|
||||
return `${API_BASE}/user/avatar/${filename}`
|
||||
return getAvatarUrl(filename)
|
||||
})
|
||||
|
||||
async function fetchUserInfo() {
|
||||
@@ -28,10 +28,17 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(nickname: string) {
|
||||
await api.updateNickname(nickname)
|
||||
if (userInfo.value) userInfo.value.nickname = nickname
|
||||
uni.setStorageSync('xc:nickname', nickname)
|
||||
async function updateProfile(data: { nickname?: string; slogan?: string }) {
|
||||
const nickname = data.nickname ?? userInfo.value?.nickname ?? ''
|
||||
const slogan = data.slogan ?? userInfo.value?.slogan ?? ''
|
||||
await api.updateNickname(nickname, slogan)
|
||||
if (userInfo.value) {
|
||||
if (data.nickname !== undefined) userInfo.value.nickname = data.nickname
|
||||
if (data.slogan !== undefined) userInfo.value.slogan = data.slogan
|
||||
}
|
||||
if (data.nickname !== undefined) {
|
||||
uni.setStorageSync('xc:nickname', data.nickname)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAvatar(filePath: string) {
|
||||
@@ -41,5 +48,5 @@ export const useUserStore = defineStore('user', () => {
|
||||
return avatarUrl.value
|
||||
}
|
||||
|
||||
return { userInfo, nickname, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar }
|
||||
return { userInfo, nickname, slogan, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar }
|
||||
})
|
||||
|
||||
311
client/src/styles/mixins.scss
Normal file
311
client/src/styles/mixins.scss
Normal file
@@ -0,0 +1,311 @@
|
||||
/* ===== 公共 Mixins ===== */
|
||||
|
||||
// 页面基础样式
|
||||
@mixin page-base {
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
padding-bottom: 180rpx;
|
||||
}
|
||||
|
||||
// 粘性头部
|
||||
@mixin sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
// 状态栏
|
||||
@mixin status-bar {
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
// 卡片
|
||||
@mixin card($radius: $radius-2xl) {
|
||||
background: $surface;
|
||||
border-radius: $radius;
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-md;
|
||||
}
|
||||
|
||||
// 导航栏
|
||||
@mixin nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $space-sm $space-xl $space-md;
|
||||
}
|
||||
|
||||
// 返回按钮
|
||||
@mixin nav-back {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.6; }
|
||||
}
|
||||
|
||||
// 页面标题
|
||||
@mixin page-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: $font-2xl;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
padding: $space-sm 0 $space-md;
|
||||
}
|
||||
|
||||
// 状态空/错误/加载
|
||||
@mixin state-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $space-sm;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
@mixin state-text {
|
||||
font-size: $font-lg;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
// Tabs
|
||||
@mixin tabs-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $space-sm;
|
||||
}
|
||||
|
||||
@mixin tabs {
|
||||
display: inline-flex;
|
||||
background: $surface-warm;
|
||||
border-radius: $radius-lg;
|
||||
padding: $space-xs;
|
||||
}
|
||||
|
||||
@mixin tab {
|
||||
padding: $space-sm $space-xl;
|
||||
border-radius: $radius-lg;
|
||||
font-size: $font-lg;
|
||||
color: $text-sec;
|
||||
transition: all $transition-normal;
|
||||
|
||||
&.active {
|
||||
background: $surface;
|
||||
color: $primary;
|
||||
font-weight: 600;
|
||||
box-shadow: $shadow-sm;
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗
|
||||
@mixin modal-mask {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
// 高层级弹窗(紧急公告、身份选择等需要覆盖其他弹窗的场景)
|
||||
@mixin modal-mask-high {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin bottom-modal {
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
background: $surface;
|
||||
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@mixin modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $space-lg $space-xl $space-md;
|
||||
border-bottom: 2rpx solid $border;
|
||||
}
|
||||
|
||||
@mixin modal-title {
|
||||
font-size: $font-xl;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
@mixin modal-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: $border;
|
||||
&:active { background: darken($border, 5%); }
|
||||
}
|
||||
|
||||
// 列表项
|
||||
@mixin list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-md;
|
||||
padding: $space-lg $space-lg;
|
||||
background: $surface;
|
||||
border-radius: $radius-xl;
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-md;
|
||||
margin-bottom: $space-sm;
|
||||
}
|
||||
|
||||
// 输入框
|
||||
@mixin input {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
padding: 0 $space-md;
|
||||
background: $bg;
|
||||
border-radius: $radius-lg;
|
||||
border: 2rpx solid $border;
|
||||
font-size: $font-lg;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
// 按钮
|
||||
@mixin btn-primary {
|
||||
height: 80rpx;
|
||||
padding: 0 $space-lg;
|
||||
background: linear-gradient(135deg, $primary, $primary-dark);
|
||||
border-radius: $radius-lg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:active { opacity: 0.8; }
|
||||
}
|
||||
|
||||
@mixin btn-primary-text {
|
||||
font-size: $font-lg;
|
||||
font-weight: 600;
|
||||
color: $surface;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@mixin btn-ghost {
|
||||
padding: $space-sm $space-md;
|
||||
background: $surface-warm;
|
||||
border-radius: $radius-md;
|
||||
&:active { background: $border; }
|
||||
}
|
||||
|
||||
@mixin btn-ghost-text {
|
||||
font-size: $font-md;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
// Shimmer 动画
|
||||
@mixin shimmer {
|
||||
background: linear-gradient(90deg, $border 25%, $surface-warm 50%, $border 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
// Flex 工具
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// 文本溢出
|
||||
@mixin text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 安全区域
|
||||
@mixin safe-bottom {
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
// ===== 入场动画 =====
|
||||
|
||||
// 卡片淡入上浮
|
||||
@mixin fade-in-up($delay: 0s) {
|
||||
animation: fadeInUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) $delay both;
|
||||
}
|
||||
|
||||
// 列表项交错淡入
|
||||
@mixin stagger-item($index: 0, $base-delay: 0.05s) {
|
||||
opacity: 0;
|
||||
animation: fadeInUp 0.35s cubic-bezier(0.23, 1, 0.32, 1) ($index * $base-delay) both;
|
||||
}
|
||||
|
||||
// 数字跳动
|
||||
@mixin count-up {
|
||||
animation: countPulse 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
// 弹性缩放
|
||||
@mixin bounce-in($delay: 0s) {
|
||||
animation: bounceIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) $delay both;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(24rpx);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounceIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes countPulse {
|
||||
0% { transform: scale(1); }
|
||||
40% { transform: scale(1.08); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
// 尊重减弱动画偏好
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fade-in-up,
|
||||
.stagger-item,
|
||||
.bounce-in {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,69 @@
|
||||
/* Design Tokens */
|
||||
/* ===== Design Tokens ===== */
|
||||
|
||||
// 颜色系统
|
||||
$primary: #FF8C69;
|
||||
$primary-light: #FFB89A;
|
||||
$primary-light: #FFE8E0;
|
||||
$primary-dark: #E67355;
|
||||
$secondary: #6C9BCF;
|
||||
$accent: #FFD166;
|
||||
|
||||
$bg: #FFF8F0;
|
||||
$surface: #FFFFFF;
|
||||
$surface-warm: #FFF0E6;
|
||||
|
||||
$text: #2D1B1B;
|
||||
$text-sec: #8B7E7E;
|
||||
$text-muted: #BFB3B3;
|
||||
|
||||
$success: #7BC67E;
|
||||
$warning: #FFB347;
|
||||
$danger: #FF6B6B;
|
||||
$danger-light: #FFE8E8;
|
||||
|
||||
$border: #F0E0D6;
|
||||
|
||||
$shadow-clay: 4px 4px 12px rgba(45, 27, 27, 0.08), inset -2px -2px 6px rgba(45, 27, 27, 0.04);
|
||||
$shadow-clay-hover: 6px 6px 16px rgba(45, 27, 27, 0.12), inset -2px -2px 6px rgba(45, 27, 27, 0.04);
|
||||
$shadow-clay-pressed: inset 2px 2px 6px rgba(45, 27, 27, 0.1);
|
||||
// 间距系统
|
||||
$space-xs: 8rpx;
|
||||
$space-sm: 16rpx;
|
||||
$space-md: 24rpx;
|
||||
$space-lg: 32rpx;
|
||||
$space-xl: 40rpx;
|
||||
$space-2xl: 48rpx;
|
||||
|
||||
$radius-sm: 8rpx;
|
||||
$radius-md: 12rpx;
|
||||
$radius-lg: 16rpx;
|
||||
$radius-xl: 20rpx;
|
||||
// 圆角系统
|
||||
$radius-xs: 8rpx;
|
||||
$radius-sm: 12rpx;
|
||||
$radius-md: 16rpx;
|
||||
$radius-lg: 20rpx;
|
||||
$radius-xl: 28rpx;
|
||||
$radius-2xl: 40rpx;
|
||||
$radius-full: 9999rpx;
|
||||
|
||||
/* uCharts theme */
|
||||
// 字体系统
|
||||
$font-xs: 20rpx;
|
||||
$font-sm: 22rpx;
|
||||
$font-md: 24rpx;
|
||||
$font-base: 26rpx;
|
||||
$font-lg: 28rpx;
|
||||
$font-xl: 32rpx;
|
||||
$font-2xl: 36rpx;
|
||||
$font-3xl: 40rpx;
|
||||
$font-4xl: 56rpx;
|
||||
|
||||
// 阴影系统
|
||||
$shadow-sm: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
|
||||
$shadow-md: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
||||
$shadow-lg: 8rpx 8rpx 24rpx rgba(45, 27, 27, 0.12);
|
||||
$shadow-clay: $shadow-md, inset -2rpx -2rpx 6rpx rgba(45, 27, 27, 0.04);
|
||||
$shadow-clay-hover: 6rpx 6rpx 16rpx rgba(45, 27, 27, 0.12), inset -2rpx -2rpx 6rpx rgba(45, 27, 27, 0.04);
|
||||
$shadow-clay-pressed: inset 2rpx 2rpx 6rpx rgba(45, 27, 27, 0.1);
|
||||
|
||||
// 动画
|
||||
$transition-fast: 0.15s ease;
|
||||
$transition-normal: 0.2s ease;
|
||||
$transition-slow: 0.3s ease;
|
||||
|
||||
/* ===== uCharts theme ===== */
|
||||
page {
|
||||
--u-charts-color-0: #FF8C69;
|
||||
--u-charts-color-1: #FFD166;
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
*/
|
||||
|
||||
let _resolve: () => void
|
||||
const _ready = new Promise<void>(resolve => { _resolve = resolve })
|
||||
let _reject: (err: Error) => void
|
||||
const _ready = new Promise<void>((resolve, reject) => {
|
||||
_resolve = resolve
|
||||
_reject = reject
|
||||
})
|
||||
|
||||
/** 标记 App 启动完成(登录成功) */
|
||||
export function markReady() { _resolve() }
|
||||
|
||||
/** 等待 App 就绪,最多等 3 秒超时 */
|
||||
/** 等待 App 就绪,最多等 8 秒超时 */
|
||||
export function waitForReady(): Promise<void> {
|
||||
return Promise.race([
|
||||
_ready,
|
||||
new Promise<void>(resolve => setTimeout(resolve, 3000))
|
||||
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('App ready timeout')), 8000))
|
||||
])
|
||||
}
|
||||
|
||||
8
client/src/utils/avatar.ts
Normal file
8
client/src/utils/avatar.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { API_BASE } from '@/config'
|
||||
|
||||
/** 将头像文件名转为完整 URL */
|
||||
export function getAvatarUrl(filename: string): string {
|
||||
if (!filename) return ''
|
||||
if (filename.startsWith('http') || filename.startsWith('blob:')) return filename
|
||||
return `${API_BASE}/user/avatar/${filename}`
|
||||
}
|
||||
@@ -36,7 +36,11 @@ export function formatDate(date: string | Date): string {
|
||||
if (diff === 1) return '昨天'
|
||||
if (diff === 2) return '前天'
|
||||
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
// 跨年显示年份
|
||||
const isCurrentYear = d.getFullYear() === now.getFullYear()
|
||||
return isCurrentYear
|
||||
? `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
: `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
export function formatMonth(date: string): string {
|
||||
@@ -44,9 +48,12 @@ export function formatMonth(date: string): string {
|
||||
return `${year}年${parseInt(month)}月`
|
||||
}
|
||||
|
||||
/** 获取当前月份(UTC+8 时区,与服务端 date.ts 逻辑一致) */
|
||||
export function getCurrentMonth(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
const offset = 8 * 60 * 60 * 1000
|
||||
const local = new Date(d.getTime() + offset)
|
||||
return local.toISOString().slice(0, 7)
|
||||
}
|
||||
|
||||
export function formatAmountRaw(fen: number): string {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { API_BASE } from '@/config'
|
||||
import type { LoginResult } from '@/api/auth'
|
||||
import { trackApiError } from './tracker'
|
||||
|
||||
interface RequestOptions {
|
||||
url: string
|
||||
@@ -6,28 +8,85 @@ interface RequestOptions {
|
||||
data?: any
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
token: string
|
||||
userId: number
|
||||
nickname: string
|
||||
avatar_url: string
|
||||
role?: string
|
||||
}
|
||||
/** 是否正在刷新 token(并发控制:只允许一次刷新) */
|
||||
let isRefreshing = false
|
||||
/** 刷新期间挂起的请求,刷新成功后统一重试 */
|
||||
let pendingRequests: Array<{
|
||||
resolve: (v: any) => void
|
||||
reject: (e: any) => void
|
||||
options: RequestOptions
|
||||
}> = []
|
||||
|
||||
let isRedirectingToLogin = false
|
||||
/** 重登录期间挂起的请求,登录成功后统一重试 */
|
||||
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = []
|
||||
/** 保存登录结果到本地存储(兼容新旧 token 格式) */
|
||||
export function saveLoginResult(data: any) {
|
||||
// 向后兼容:旧版返回 token 字段映射到 accessToken
|
||||
const accessToken = data.accessToken || data.token || ''
|
||||
const refreshToken = data.refreshToken || ''
|
||||
const expiresIn = data.expiresIn || 7200
|
||||
|
||||
/** 保存登录结果到本地存储 */
|
||||
export function saveLoginResult(data: LoginResult) {
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
uni.setStorageSync('xc:token', accessToken)
|
||||
uni.setStorageSync('xc:refreshToken', refreshToken)
|
||||
uni.setStorageSync('xc:tokenExpiresIn', expiresIn)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
uni.setStorageSync('xc:nickname', data.nickname)
|
||||
uni.setStorageSync('xc:avatar_url', data.avatar_url)
|
||||
if (data.role) uni.setStorageSync('xc:role', data.role)
|
||||
}
|
||||
|
||||
/** 执行重登录 */
|
||||
/** 清除本地登录信息 */
|
||||
function clearLoginInfo() {
|
||||
uni.removeStorageSync('xc:token')
|
||||
uni.removeStorageSync('xc:refreshToken')
|
||||
uni.removeStorageSync('xc:tokenExpiresIn')
|
||||
uni.removeStorageSync('xc:userId')
|
||||
uni.removeStorageSync('xc:nickname')
|
||||
uni.removeStorageSync('xc:avatar_url')
|
||||
uni.removeStorageSync('xc:role')
|
||||
uni.removeStorageSync('xc:currentGroupId')
|
||||
}
|
||||
|
||||
/** 跳转到登录页 */
|
||||
function redirectToLogin() {
|
||||
clearLoginInfo()
|
||||
// H5 环境刷新页面重新走登录流程
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新 token:用 refreshToken 换取新的双 token */
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
const storedRefreshToken = uni.getStorageSync('xc:refreshToken')
|
||||
if (!storedRefreshToken) return null
|
||||
|
||||
try {
|
||||
// 直接用 uni.request 发起,避免走 request 函数导致循环
|
||||
const res: any = await new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: API_BASE + '/auth/refresh',
|
||||
method: 'POST',
|
||||
data: { refreshToken: storedRefreshToken },
|
||||
timeout: 10000,
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
|
||||
if (res.statusCode === 200 && res.data?.code === 0) {
|
||||
const { accessToken, refreshToken: newRefreshToken, expiresIn } = res.data.data
|
||||
uni.setStorageSync('xc:token', accessToken)
|
||||
uni.setStorageSync('xc:refreshToken', newRefreshToken)
|
||||
uni.setStorageSync('xc:tokenExpiresIn', expiresIn || 7200)
|
||||
return accessToken
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行重登录(没有 refreshToken 或刷新失败时的兜底) */
|
||||
async function reLogin(): Promise<void> {
|
||||
// H5 环境:demo 登录
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -53,6 +112,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
url: API_BASE + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
timeout: 15000, // 15秒超时
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
@@ -61,36 +121,55 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
const { statusCode, data } = res
|
||||
|
||||
if (statusCode === 401) {
|
||||
uni.removeStorageSync('xc:token')
|
||||
uni.removeStorageSync('xc:userId')
|
||||
const errorCode = data?.code
|
||||
|
||||
// 加入重试队列
|
||||
pendingRetries.push({ resolve, reject, options })
|
||||
// code 40101 = token 过期 → 尝试用 refreshToken 刷新
|
||||
if (errorCode === 40101) {
|
||||
// 加入等待队列
|
||||
pendingRequests.push({ resolve, reject, options })
|
||||
|
||||
if (!isRedirectingToLogin) {
|
||||
isRedirectingToLogin = true
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true
|
||||
|
||||
reLogin()
|
||||
.then(() => {
|
||||
// 处理快照时已入队的 + reLogin 期间新入队的请求
|
||||
const retries = [...pendingRetries]
|
||||
pendingRetries = []
|
||||
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
|
||||
})
|
||||
.catch(() => {
|
||||
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
||||
const retries = [...pendingRetries]
|
||||
pendingRetries = []
|
||||
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
|
||||
})
|
||||
.finally(() => { isRedirectingToLogin = false })
|
||||
refreshAccessToken()
|
||||
.then((newToken) => {
|
||||
if (newToken) {
|
||||
// 刷新成功:重试所有等待中的请求
|
||||
const retries = [...pendingRequests]
|
||||
pendingRequests = []
|
||||
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
|
||||
} else {
|
||||
// 刷新失败:尝试完整重登录
|
||||
return reLogin().then(() => {
|
||||
const retries = [...pendingRequests]
|
||||
pendingRequests = []
|
||||
retries.forEach(r => request(r.options).then(r.resolve).catch(r.reject))
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 重登录也失败
|
||||
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' })
|
||||
const retries = [...pendingRequests]
|
||||
pendingRequests = []
|
||||
retries.forEach(r => r.reject({ code: 40100, message: '未登录' }))
|
||||
})
|
||||
.finally(() => { isRefreshing = false })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// code 40100 = token 无效/未登录 → 直接清 token 跳登录
|
||||
redirectToLogin()
|
||||
reject({ code: 40100, message: data?.message || '未登录' })
|
||||
return
|
||||
}
|
||||
|
||||
if (data.code === 0) {
|
||||
resolve(data.data)
|
||||
} else {
|
||||
// 埋点:API 业务错误
|
||||
trackApiError(options.url, statusCode, data.message || '请求失败')
|
||||
// "用户不存在" 不弹 toast(首次登录竞态)
|
||||
if (data.code !== 40400 || data.message !== '用户不存在') {
|
||||
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
|
||||
@@ -99,6 +178,8 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
}
|
||||
},
|
||||
fail: (err: any) => {
|
||||
// 埋点:网络错误
|
||||
trackApiError(options.url, 0, '网络错误')
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
reject(err)
|
||||
}
|
||||
|
||||
140
client/src/utils/tracker.ts
Normal file
140
client/src/utils/tracker.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 前端埋点工具
|
||||
* 记录用户关键操作和错误信息
|
||||
*/
|
||||
|
||||
import { request } from './request'
|
||||
|
||||
interface TrackEvent {
|
||||
event: string
|
||||
page?: string
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
/** 上报队列(批量上报,减少请求) */
|
||||
let eventQueue: TrackEvent[] = []
|
||||
let flushTimer: number | null = null
|
||||
const FLUSH_INTERVAL = 500 // 500ms 攒批
|
||||
const MAX_QUEUE_SIZE = 10 // 10 条立即发送
|
||||
|
||||
/** 获取当前页面路径 */
|
||||
function getCurrentPage(): string {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 0) {
|
||||
return `/${pages[pages.length - 1].route}`
|
||||
}
|
||||
return '/unknown'
|
||||
}
|
||||
|
||||
/** 批量上报事件 */
|
||||
async function flushEvents() {
|
||||
if (eventQueue.length === 0) return
|
||||
|
||||
const events = [...eventQueue]
|
||||
eventQueue = []
|
||||
|
||||
try {
|
||||
await request({
|
||||
url: '/track',
|
||||
method: 'POST',
|
||||
data: { events }
|
||||
})
|
||||
} catch {
|
||||
// 上报失败不处理,避免影响用户体验
|
||||
}
|
||||
}
|
||||
|
||||
/** 添加事件到队列 */
|
||||
function addToQueue(event: TrackEvent) {
|
||||
eventQueue.push(event)
|
||||
|
||||
// 队列满时立即上报
|
||||
if (eventQueue.length >= MAX_QUEUE_SIZE) {
|
||||
flushEvents()
|
||||
return
|
||||
}
|
||||
|
||||
// 设置定时上报
|
||||
if (!flushTimer) {
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null
|
||||
flushEvents()
|
||||
}, FLUSH_INTERVAL) as unknown as number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪页面访问
|
||||
*/
|
||||
export function trackPageView(pagePath?: string) {
|
||||
addToQueue({
|
||||
event: 'page_view',
|
||||
page: pagePath || getCurrentPage()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪用户操作
|
||||
* @param action 操作名称,如 'click_save', 'submit_form'
|
||||
* @param data 附加数据
|
||||
*/
|
||||
export function trackAction(action: string, data?: Record<string, any>) {
|
||||
addToQueue({
|
||||
event: 'action',
|
||||
page: getCurrentPage(),
|
||||
data: { action, ...data }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪错误
|
||||
*/
|
||||
export function trackError(error: Error | string, extra?: Record<string, any>) {
|
||||
const errorInfo = typeof error === 'string'
|
||||
? { message: error }
|
||||
: { message: error.message, stack: error.stack?.slice(0, 500) }
|
||||
|
||||
addToQueue({
|
||||
event: 'error',
|
||||
page: getCurrentPage(),
|
||||
data: { ...errorInfo, ...extra }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪 API 请求失败
|
||||
*/
|
||||
export function trackApiError(url: string, status: number, message: string) {
|
||||
addToQueue({
|
||||
event: 'api_error',
|
||||
page: getCurrentPage(),
|
||||
data: { url, status, message }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局错误监听
|
||||
*/
|
||||
export function setupErrorTracking() {
|
||||
// Vue 错误处理
|
||||
uni.onError((err: string) => {
|
||||
trackError(err, { type: 'global' })
|
||||
})
|
||||
|
||||
// 未处理的 Promise rejection
|
||||
// #ifdef H5
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
trackError(String(event.reason), { type: 'unhandledrejection' })
|
||||
})
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
export default {
|
||||
trackPageView,
|
||||
trackAction,
|
||||
trackError,
|
||||
trackApiError,
|
||||
setupErrorTracking
|
||||
}
|
||||
14
client/vitest.config.ts
Normal file
14
client/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
581
client/yarn.lock
581
client/yarn.lock
File diff suppressed because it is too large
Load Diff
577
docs/arch-alignment.md
Normal file
577
docs/arch-alignment.md
Normal file
@@ -0,0 +1,577 @@
|
||||
# 小菜记账 增量架构设计文档
|
||||
|
||||
> 版本:v1.0 | 架构师:高见远(Gao) | 日期:2025-07
|
||||
|
||||
---
|
||||
|
||||
## Part A: 系统设计
|
||||
|
||||
### 1. 实现方案 + 框架选型
|
||||
|
||||
#### 1.1 分类拖拽排序(P0-1)
|
||||
|
||||
**技术挑战**:微信小程序不支持 HTML5 Drag and Drop API,Uni-app 的 `movable-area`/`movable-view` 适合单元素拖拽而非列表重排。
|
||||
|
||||
**方案**:基于 Touch 事件的自定义拖拽排序组件 `DragSortList`。
|
||||
|
||||
- 监听 `@touchstart` / `@touchmove` / `@touchend` 三组事件
|
||||
- 拖拽时通过 `transform: translateY()` 实现元素位移动画
|
||||
- 拖拽过程中实时计算目标索引,交换数组元素位置
|
||||
- 松手后调用已就绪的 `sortCategories(ids)` API 批量更新排序
|
||||
- 支出/收入各自独立排序(复用现有 `tabType` 切换 + `currentCategories` computed)
|
||||
- **不引入第三方拖拽库**——Uni-app 小程序环境下无成熟可用方案,自研可控性更高
|
||||
|
||||
#### 1.2 服务端导出流式响应(P2-3)
|
||||
|
||||
**方案**:Node.js 原生 Stream + `pipeline()` 管道。
|
||||
|
||||
- **< 1 万条**:使用 `Readable.from()` 创建内存流,逐行 push 数据,通过 `pipeline()` 管道传输到 Response
|
||||
- **≥ 1 万条**:先写入临时文件(`os.tmpdir()`),再 `fs.createReadStream()` 流式返回,完成后删除临时文件
|
||||
- CSV 格式:手动拼装(无需额外依赖),添加 BOM 头确保中文兼容
|
||||
- JSON 格式:流式写入 `[` + 逐条 `,` 分隔 + `]`,避免全量 JSON.stringify
|
||||
- Response Headers:`Content-Type: text/csv; charset=utf-8` / `application/json; charset=utf-8`,`Content-Disposition: attachment; filename=...`
|
||||
- 超时保护:流式响应不设 `express.json()` body 限制,但设 60s 超时
|
||||
|
||||
#### 1.3 标签系统数据库设计(P3-2~4)
|
||||
|
||||
**设计原则**:标签不区分收支类型,跨分类使用,轻量关联表。
|
||||
|
||||
- `tags` 表:用户维度,每用户最多 20 个标签,8 色预设
|
||||
- `transaction_tags` 表:多对多关联,每笔交易最多 5 个标签
|
||||
- 标签与交易在同一事务中写入(`POST/PUT /transactions` 扩展 `tagIds` 字段)
|
||||
- 查询交易列表时 LEFT JOIN 聚合标签信息(避免 N+1 查询)
|
||||
- 统计按标签聚合使用 `GROUP BY tag_id`
|
||||
|
||||
#### 1.4 数据导入(P2-2)
|
||||
|
||||
**方案**:JSON 格式导入,服务端校验 + 批量写入 + 去重。
|
||||
|
||||
- 接收 JSON 数组,校验字段格式(必填:date, type, amount, category_name)
|
||||
- 按 `date + type + amount + note` 组合去重(查现有记录比对)
|
||||
- 使用 `INSERT ... VALUES (...), (...), ...` 批量写入(每批 100 条)
|
||||
- 返回结果:`{ total, imported, skipped, errors[] }`
|
||||
|
||||
#### 1.5 统计年度/周视图(P2-1)
|
||||
|
||||
**方案**:后端 `period` 参数扩展 + 前端维度切换器。
|
||||
|
||||
- 后端:`GET /stats/category` 和 `GET /stats/trend` 新增 `period=week|month|year` 参数
|
||||
- `week`:基于当前日期计算本周一至本周日,趋势 X 轴为周一~周日
|
||||
- `month`:保持现有行为(默认)
|
||||
- `year`:基于当前年份 1-12 月,趋势 X 轴为 1月~12月
|
||||
- 年视图日期范围计算:`startDate = ${year}-01-01`, `endDate = ${year}-12-31`
|
||||
- 周视图日期范围计算:获取本周一和本周日
|
||||
- 趋势数据 GROUP BY 逻辑:年视图按月 GROUP,周视图按日 GROUP
|
||||
|
||||
---
|
||||
|
||||
### 2. 文件列表及相对路径
|
||||
|
||||
#### 2.1 新建文件
|
||||
|
||||
| 文件路径 | 说明 |
|
||||
|---------|------|
|
||||
| `client/src/api/backup.ts` | 备份管理 API(下载备份) |
|
||||
| `client/src/api/tag.ts` | 标签 CRUD + 统计 API |
|
||||
| `client/src/api/export.ts` | 服务端导出 API |
|
||||
| `client/src/api/health.ts` | 健康检查 API |
|
||||
| `client/src/stores/tag.ts` | 标签 Pinia Store |
|
||||
| `client/src/components/DragSortList/DragSortList.vue` | 拖拽排序列表组件 |
|
||||
| `client/src/pages/backup-manage/index.vue` | 备份管理页面(仅管理员) |
|
||||
| `client/src/pages/data-import/index.vue` | 数据导入页面 |
|
||||
| `client/src/pages/tag-manage/index.vue` | 标签管理页面 |
|
||||
| `server/src/routes/tag.ts` | 标签 CRUD + 按标签统计路由 |
|
||||
| `server/src/routes/export.ts` | 服务端导出路由 |
|
||||
|
||||
#### 2.2 修改文件
|
||||
|
||||
| 文件路径 | 主要变更 |
|
||||
|---------|---------|
|
||||
| `server/src/db/schema.sql` | 新增 tags、transaction_tags 表定义 |
|
||||
| `server/src/db/init.ts` | 新增 tags、transaction_tags 表迁移 |
|
||||
| `server/src/routes/feedback.ts` | 新增 `GET /mine` 端点(用户查看自己的反馈) |
|
||||
| `server/src/routes/backup.ts` | 新增 `GET /:id/download` 端点(临时签名 URL) |
|
||||
| `server/src/routes/stats.ts` | category/trend 端点增加 `period` 参数支持 |
|
||||
| `server/src/routes/transaction.ts` | POST/PUT 支持 `tagIds`;GET 支持 `tagId` 筛选;GET/:id 返回 tags |
|
||||
| `server/src/index.ts` | health 端点移到 authMiddleware 之前;注册 tag、export 路由 |
|
||||
| `client/src/api/feedback.ts` | 新增 `getMyFeedbacks()` API 函数 |
|
||||
| `client/src/api/stats.ts` | 修改 `getCategoryStats`、`getTrend` 支持 `period` 参数 |
|
||||
| `client/src/api/transaction.ts` | 新增 `importTransactions()`;类型扩展 `tagIds`、`tagId` |
|
||||
| `client/src/api/admin.ts` | 新增 `getHealth()` API 函数 |
|
||||
| `client/src/stores/category.ts` | 无需修改(sortCategories 已就绪) |
|
||||
| `client/src/stores/stats.ts` | fetchCategoryStats/fetchTrend 支持 period 参数 |
|
||||
| `client/src/pages/category-manage/index.vue` | 集成 DragSortList 拖拽排序 |
|
||||
| `client/src/pages/feedback/index.vue` | 新增"我的反馈"Tab |
|
||||
| `client/src/pages/stats/index.vue` | 添加维度切换器(周/月/年) |
|
||||
| `client/src/pages/profile/index.vue` | 导出面板增加"服务端导出"选项 |
|
||||
| `client/src/pages/add/index.vue` | 添加标签选择入口 |
|
||||
| `client/src/pages/bills/index.vue` | 添加标签筛选功能 |
|
||||
| `client/src/pages/admin/index.vue` | 添加服务状态卡片 |
|
||||
| `client/src/pages.json` | 注册新页面路由 |
|
||||
|
||||
---
|
||||
|
||||
### 3. 数据结构和接口(类图)
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Tag {
|
||||
+int id
|
||||
+int user_id
|
||||
+string name
|
||||
+string color
|
||||
+string created_at
|
||||
}
|
||||
|
||||
class TransactionTag {
|
||||
+int transaction_id
|
||||
+int tag_id
|
||||
}
|
||||
|
||||
class Transaction {
|
||||
+int id
|
||||
+int user_id
|
||||
+int amount
|
||||
+string type
|
||||
+int category_id
|
||||
+string note
|
||||
+string date
|
||||
+int group_id
|
||||
+int recurring_id
|
||||
+Tag[] tags
|
||||
}
|
||||
|
||||
class Feedback {
|
||||
+int id
|
||||
+int user_id
|
||||
+string type
|
||||
+string content
|
||||
+string contact
|
||||
+string status
|
||||
+string admin_reply
|
||||
+string created_at
|
||||
}
|
||||
|
||||
class Category {
|
||||
+int id
|
||||
+int user_id
|
||||
+string name
|
||||
+string icon
|
||||
+string color
|
||||
+string type
|
||||
+int sort_order
|
||||
+int is_custom
|
||||
}
|
||||
|
||||
Transaction "1" --o "*" TransactionTag : has
|
||||
Tag "1" --o "*" TransactionTag : referenced_by
|
||||
Transaction ..> Category : belongs_to
|
||||
|
||||
note for Tag "每用户最多 20 个标签\n8 色预设颜色选择器\n标签不区分收支类型"
|
||||
note for TransactionTag "复合主键 (transaction_id, tag_id)\n每笔交易最多 5 个标签"
|
||||
```
|
||||
|
||||
#### 3.1 新增数据库表
|
||||
|
||||
**tags 表**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(20) NOT NULL COMMENT '标签名称',
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_name (user_id, name),
|
||||
INDEX idx_user (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
**transaction_tags 表**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS transaction_tags (
|
||||
transaction_id INT NOT NULL,
|
||||
tag_id INT NOT NULL,
|
||||
PRIMARY KEY (transaction_id, tag_id),
|
||||
INDEX idx_tag (tag_id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
#### 3.2 新增/修改 API 端点
|
||||
|
||||
##### 标签 CRUD
|
||||
|
||||
| 方法 | 路径 | 请求 | 响应 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| GET | `/api/tags` | — | `{ list: Tag[] }` | 获取当前用户所有标签 |
|
||||
| POST | `/api/tags` | `{ name, color }` | `{ id }` | 创建标签(限 20 个/用户) |
|
||||
| PUT | `/api/tags/:id` | `{ name?, color? }` | — | 更新标签 |
|
||||
| DELETE | `/api/tags/:id` | — | — | 删除标签(级联删除关联) |
|
||||
| GET | `/api/stats/by-tag` | `?month&period&type` | `TagStat[]` | 按标签统计 |
|
||||
|
||||
**TagStat 类型**:
|
||||
```ts
|
||||
{ id: number; name: string; color: string; amount: number; count: number }
|
||||
```
|
||||
|
||||
##### 用户反馈列表
|
||||
|
||||
| 方法 | 路径 | 请求 | 响应 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| GET | `/api/feedback/mine` | `?page&pageSize` | `{ list, total, page, pageSize }` | 当前用户的反馈列表 |
|
||||
|
||||
##### 备份下载
|
||||
|
||||
| 方法 | 路径 | 请求 | 响应 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| GET | `/api/backup/:id/download` | — | 文件流 | 下载备份文件(临时签名 URL,1h 有效) |
|
||||
|
||||
**实现方式**:在 `backup` 表中无需存储,使用 HMAC-SHA256 生成带过期时间的签名 token,下载时校验签名。签名格式:`backupId:timestamp:hmac-sha256(backupId:timestamp)`。将签名作为 `?token=xxx` 传给前端,下载端点校验 token 有效性(无需认证中间件)。
|
||||
|
||||
##### 统计年度/周视图
|
||||
|
||||
| 方法 | 路径 | 请求参数 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| GET | `/api/stats/category` | 新增 `period=week\|month\|year` | 周/月/年分类统计 |
|
||||
| GET | `/api/stats/trend` | 新增 `period=week\|month\|year` | 周/月/年趋势数据 |
|
||||
|
||||
**period 参数逻辑**:
|
||||
- `week`(新增):`startDate` = 本周一,`endDate` = 本周日,趋势按日 GROUP
|
||||
- `month`(默认):保持现有行为,趋势按日 GROUP
|
||||
- `year`(新增):`startDate` = 当年1月1日,`endDate` = 当年12月31日,趋势按月 GROUP(`DATE_FORMAT(date, '%Y-%m')`)
|
||||
|
||||
**年视图趋势响应**(新增 `label` 字段):
|
||||
```ts
|
||||
{ label: string; date: string; amount: number }[]
|
||||
// 年视图 label = "1月"~"12月"
|
||||
// 周视图 label = "周一"~"周日"
|
||||
// 月视图 label = "1日"~"31日"(保持兼容)
|
||||
```
|
||||
|
||||
##### 数据导入
|
||||
|
||||
| 方法 | 路径 | 请求 | 响应 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| POST | `/api/transactions/import` | `{ items: ImportItem[] }` | `{ total, imported, skipped, errors[] }` | 批量导入 |
|
||||
|
||||
**ImportItem 类型**:
|
||||
```ts
|
||||
{
|
||||
date: string // 必填,YYYY-MM-DD
|
||||
type: string // 必填,expense | income
|
||||
amount: number // 必填,单位:分
|
||||
category_name: string // 可选,匹配不到则归入"未分类"
|
||||
note?: string
|
||||
tag_names?: string[] // 可选,按名称匹配标签
|
||||
}
|
||||
```
|
||||
|
||||
##### 服务端导出
|
||||
|
||||
| 方法 | 路径 | 请求参数 | 响应 | 说明 |
|
||||
|------|------|---------|------|------|
|
||||
| GET | `/api/export` | `startDate, endDate, type, format=csv\|json` | 文件流 | 流式导出 |
|
||||
|
||||
##### 健康检查(改造)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/health` | 移到 `authMiddleware` 之前,无需认证 |
|
||||
|
||||
##### 交易记录(改造)
|
||||
|
||||
| 方法 | 路径 | 变更 |
|
||||
|------|------|------|
|
||||
| POST | `/api/transactions` | 请求新增 `tagIds: number[]`(可选,最多 5 个) |
|
||||
| PUT | `/api/transactions/:id` | 请求新增 `tagIds: number[]`(可选,最多 5 个,全量替换) |
|
||||
| GET | `/api/transactions` | 新增 `tagId` 查询参数筛选 |
|
||||
| GET | `/api/transactions/:id` | 响应新增 `tags: { id, name, color }[]` |
|
||||
|
||||
---
|
||||
|
||||
### 4. 程序调用流程(时序图)
|
||||
|
||||
#### 4.1 分类拖拽排序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant P as category-manage
|
||||
participant D as DragSortList
|
||||
participant S as categoryStore
|
||||
participant A as sortCategories API
|
||||
participant B as PUT /categories/sort
|
||||
|
||||
U->>P: 长按分类项进入排序模式
|
||||
P->>P: showSortMode = true
|
||||
P->>D: 渲染 DragSortList (items=currentCategories)
|
||||
U->>D: touchstart (记录起始位置)
|
||||
U->>D: touchmove (计算偏移, 交换元素位置)
|
||||
D->>D: 实时更新 items 数组顺序
|
||||
U->>D: touchend (拖拽结束)
|
||||
D->>P: @change事件 (新顺序ids)
|
||||
P->>S: sortCategories(newIds)
|
||||
S->>A: sortCategories(ids)
|
||||
A->>B: PUT /categories/sort { ids }
|
||||
B-->>A: { code: 0 }
|
||||
A-->>S: 成功
|
||||
S->>S: fetchCategories() 刷新
|
||||
```
|
||||
|
||||
#### 4.2 数据导入
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant P as data-import
|
||||
participant A as transaction API
|
||||
participant S as POST /transactions/import
|
||||
participant DB as MySQL
|
||||
|
||||
U->>P: 选择 JSON 文件
|
||||
P->>P: uni.chooseFile / 读取文件内容
|
||||
P->>P: 解析 JSON,显示预览(条数、日期范围)
|
||||
U->>P: 确认导入
|
||||
P->>A: importTransactions(items)
|
||||
A->>S: POST /transactions/import { items }
|
||||
S->>S: 校验每条记录格式
|
||||
S->>DB: 查询已有记录 (去重比对)
|
||||
S->>DB: 批量 INSERT (每批100条)
|
||||
S->>DB: 写入 transaction_tags (如有 tag_names)
|
||||
S-->>A: { total, imported, skipped, errors }
|
||||
A-->>P: 导入结果
|
||||
P->>U: 显示导入结果(成功X条, 跳过Y条)
|
||||
```
|
||||
|
||||
#### 4.3 标签关联交易
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant P as add/index
|
||||
participant TS as tagStore
|
||||
participant TA as transaction API
|
||||
participant S as POST /transactions
|
||||
participant DB as MySQL
|
||||
|
||||
U->>P: 点击"添加标签"
|
||||
P->>P: 显示标签选择面板(已有标签 + 新建入口)
|
||||
U->>P: 选择标签(最多5个)
|
||||
P->>P: selectedTagIds 更新
|
||||
U->>P: 保存交易
|
||||
P->>TA: createTransaction({...data, tagIds})
|
||||
TA->>S: POST /transactions { amount, type, ..., tagIds }
|
||||
S->>S: 校验 tagIds (≤5, 属于当前用户)
|
||||
S->>DB: INSERT INTO transactions
|
||||
S->>DB: INSERT INTO transaction_tags (批量)
|
||||
S-->>TA: { id }
|
||||
TA-->>P: 成功
|
||||
|
||||
Note over P,U: 编辑交易时同理,PUT 全量替换 tagIds
|
||||
```
|
||||
|
||||
#### 4.4 服务端导出
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant P as profile/index
|
||||
participant A as export API
|
||||
participant S as GET /api/export
|
||||
participant DB as MySQL
|
||||
|
||||
U->>P: 点击"服务端导出"
|
||||
P->>A: serverExport(params)
|
||||
A->>S: GET /api/export?startDate=&endDate=&type=&format=
|
||||
S->>DB: SELECT COUNT(*) (判断数量)
|
||||
alt 记录 < 10000
|
||||
S->>DB: 流式查询 (cursor)
|
||||
S->>S: Readable.from() 逐行生成 CSV/JSON
|
||||
S-->>P: 流式响应 (Transfer-Encoding: chunked)
|
||||
else 记录 ≥ 10000
|
||||
S->>DB: 流式查询写入临时文件
|
||||
S->>S: fs.createReadStream()
|
||||
S-->>P: 流式响应
|
||||
S->>S: 删除临时文件
|
||||
end
|
||||
P->>U: 保存文件到本地
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 待明确事项
|
||||
|
||||
| # | 事项 | 当前假设 |
|
||||
|---|------|---------|
|
||||
| 1 | 备份下载的签名 URL 是通过新端点直接下载,还是返回签名 URL 让前端跳转? | 采用直接下载方式:`GET /api/backup/:id/download?token=xxx`,后端校验 token 后流式返回文件 |
|
||||
| 2 | 年视图/周视图的 `month` 参数如何处理? | 新增 `period` 参数,当 `period=year` 时 `month` 参数被忽略,使用当前年份;当 `period=week` 时使用当前周 |
|
||||
| 3 | 导入 JSON 的 `category_name` 匹配不到时如何处理? | 归入"未分类"(category_id = NULL),不自动创建分类 |
|
||||
| 4 | 标签预设 8 色具体色值? | 复用现有 `colorOptions`:`#FF8C69, #7BC67E, #5B9BD5, #FFD700, #FF69B4, #8B5CF6, #F97316, #06B6D4` |
|
||||
| 5 | 健康检查移到 authMiddleware 前是否影响安全? | `/api/health` 仅返回 DB 连接状态和服务器时间,不泄露敏感信息,安全无影响 |
|
||||
| 6 | 数据导入文件大小限制? | 前端限制 5MB,后端 `express.json({ limit: '5mb' })` 仅对导入端点放宽(当前全局 10kb) |
|
||||
|
||||
---
|
||||
|
||||
## Part B: 任务分解
|
||||
|
||||
### 6. 需要新增的依赖包
|
||||
|
||||
#### 后端 (server)
|
||||
|
||||
```
|
||||
无新增依赖
|
||||
```
|
||||
|
||||
> 流式导出使用 Node.js 内置 `stream`、`fs`、`os` 模块,CSV 拼装手写无需 `json2csv`,签名 URL 使用已有 `crypto` 模块。
|
||||
|
||||
#### 前端 (client)
|
||||
|
||||
```
|
||||
无新增依赖
|
||||
```
|
||||
|
||||
> 拖拽排序使用 Touch 事件自研实现,不引入第三方拖拽库。
|
||||
|
||||
---
|
||||
|
||||
### 7. 任务列表(按依赖顺序)
|
||||
|
||||
#### T01: 后端基础设施 + 数据库迁移
|
||||
|
||||
**描述**:新增 tags/transaction_tags 表、修改 health 端点位置、注册新路由模块、调整 express.json 限制。
|
||||
|
||||
**涉及文件**:
|
||||
- `server/src/db/schema.sql` — 新增 tags、transaction_tags 表定义
|
||||
- `server/src/db/init.ts` — 新增迁移逻辑(创建 tags、transaction_tags 表)
|
||||
- `server/src/index.ts` — health 移到 authMiddleware 前;注册 tag、export 路由;导入端点 body 限制调整
|
||||
- `server/src/routes/tag.ts` — 新建:标签 CRUD 端点 + 按标签统计端点
|
||||
- `server/src/routes/export.ts` — 新建:服务端流式导出端点
|
||||
|
||||
**依赖任务**:无
|
||||
|
||||
**优先级**:P0
|
||||
|
||||
---
|
||||
|
||||
#### T02: 后端 API 改造(现有路由扩展)
|
||||
|
||||
**描述**:扩展现有路由以支持新功能——反馈 mine 端点、备份下载、交易 tagIds 支持、统计 period 参数。
|
||||
|
||||
**涉及文件**:
|
||||
- `server/src/routes/feedback.ts` — 新增 `GET /mine` 端点
|
||||
- `server/src/routes/backup.ts` — 新增 `GET /:id/download` 端点(签名 URL 校验)
|
||||
- `server/src/routes/transaction.ts` — POST/PUT 支持 tagIds;GET 支持 tagId 筛选;GET/:id 返回 tags
|
||||
- `server/src/routes/stats.ts` — category/trend 增加 period 参数(week/year 视图)
|
||||
|
||||
**依赖任务**:T01(tags 表需先存在)
|
||||
|
||||
**优先级**:P0
|
||||
|
||||
---
|
||||
|
||||
#### T03: 前端 API 层 + Store 层 + 新页面
|
||||
|
||||
**描述**:新增所有前端 API 函数和 Store,创建新页面(标签管理、备份管理、数据导入),创建拖拽组件。
|
||||
|
||||
**涉及文件**:
|
||||
- `client/src/api/feedback.ts` — 新增 `getMyFeedbacks()`
|
||||
- `client/src/api/backup.ts` — 新建:备份列表 + 下载 API
|
||||
- `client/src/api/tag.ts` — 新建:标签 CRUD + 统计 API
|
||||
- `client/src/api/export.ts` — 新建:服务端导出 API
|
||||
- `client/src/api/health.ts` — 新建:健康检查 API
|
||||
- `client/src/api/stats.ts` — 修改:getCategoryStats/getTrend 支持 period
|
||||
- `client/src/api/transaction.ts` — 新增 `importTransactions()`;类型扩展 tagIds/tagId
|
||||
- `client/src/api/admin.ts` — 新增 `getHealth()`
|
||||
- `client/src/stores/tag.ts` — 新建:标签 Pinia Store
|
||||
- `client/src/stores/stats.ts` — 修改:fetchCategoryStats/fetchTrend 支持 period
|
||||
- `client/src/components/DragSortList/DragSortList.vue` — 新建:拖拽排序组件
|
||||
- `client/src/pages/tag-manage/index.vue` — 新建:标签管理页面
|
||||
- `client/src/pages/backup-manage/index.vue` — 新建:备份管理页面
|
||||
- `client/src/pages/data-import/index.vue` — 新建:数据导入页面
|
||||
- `client/src/pages.json` — 注册新页面路由
|
||||
|
||||
**依赖任务**:T01(需知 API 端点格式)
|
||||
|
||||
**优先级**:P1
|
||||
|
||||
---
|
||||
|
||||
#### T04: 前端现有页面改造
|
||||
|
||||
**描述**:改造现有页面以集成新功能——分类拖拽、反馈列表、统计维度切换、标签入口、服务端导出、健康检查卡片。
|
||||
|
||||
**涉及文件**:
|
||||
- `client/src/pages/category-manage/index.vue` — 集成 DragSortList 拖拽排序
|
||||
- `client/src/pages/feedback/index.vue` — 新增"我的反馈"Tab
|
||||
- `client/src/pages/stats/index.vue` — 添加维度切换器(周/月/年)+ 标签统计入口
|
||||
- `client/src/pages/profile/index.vue` — 导出面板增加"服务端导出"选项
|
||||
- `client/src/pages/add/index.vue` — 添加标签选择入口
|
||||
- `client/src/pages/bills/index.vue` — 添加标签筛选功能
|
||||
- `client/src/pages/admin/index.vue` — 添加服务状态卡片(30s 自动刷新)
|
||||
|
||||
**依赖任务**:T03(依赖 API 层和组件)
|
||||
|
||||
**优先级**:P1
|
||||
|
||||
---
|
||||
|
||||
#### T05: 集成联调 + 边界处理
|
||||
|
||||
**描述**:前后端联调、边界场景处理(导入去重、大量数据导出、拖拽边界)、最终测试修复。
|
||||
|
||||
**涉及文件**:
|
||||
- 可能涉及 T01~T04 中所有文件的微调
|
||||
- 重点:`server/src/routes/export.ts`(大量数据流式导出稳定性)
|
||||
- 重点:`server/src/routes/transaction.ts`(导入去重逻辑)
|
||||
- 重点:`client/src/components/DragSortList/DragSortList.vue`(拖拽边界场景)
|
||||
|
||||
**依赖任务**:T04
|
||||
|
||||
**优先级**:P2
|
||||
|
||||
---
|
||||
|
||||
### 8. 共享知识(跨文件约定)
|
||||
|
||||
```
|
||||
- API 响应格式统一:{ code: 0, data: T } 成功;{ code: 5位数字, message: string } 失败
|
||||
- 错误码约定:
|
||||
- 40001: 参数无效
|
||||
- 40002: 类型无效
|
||||
- 40100: 未登录
|
||||
- 40101: token过期
|
||||
- 40300: 无权限
|
||||
- 40400: 资源不存在
|
||||
- 42900: 请求频繁
|
||||
- 50000: 服务器内部错误
|
||||
- 金额单位统一为"分"(INT),前端展示时 / 100
|
||||
- 日期格式统一为 YYYY-MM-DD,月份为 YYYY-MM
|
||||
- 认证使用 HMAC-SHA256 Token,Bearer 格式,30天有效期
|
||||
- 公开路径(无需认证):/api/auth/login, /api/auth/demo-login, /api/health, /api/backup/:id/download(token 校验)
|
||||
- 前端 request() 函数自动处理 401 重登录、错误 toast
|
||||
- Pinia Store 方法命名:fetch* (获取数据)、add/create (新增)、update (更新)、delete (删除)
|
||||
- 前端 API 函数命名:get* (GET), create*/submit* (POST), update*/sort* (PUT), delete* (DELETE)
|
||||
- SCSS 变量:$primary=#FF8C69, $success=#7BC67E, $danger=#FF6B6B, $text=#2D1B1B, $surface=#FFFFFF, $bg=#FFF8F0
|
||||
- 组件样式使用 scoped + @import '@/styles/mixins.scss'
|
||||
- 标签预设 8 色:#FF8C69, #7BC67E, #5B9BD5, #FFD700, #FF69B4, #8B5CF6, #F97316, #06B6D4
|
||||
- 每笔交易最多 5 个标签,每用户最多 20 个标签
|
||||
- 导入文件限制 5MB,导出流式响应超时 60s
|
||||
- 分页参数:page (从1开始), pageSize (默认20, 最大100)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. 任务依赖图
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
T01[T01: 后端基础设施+DB迁移] --> T02[T02: 后端API改造]
|
||||
T01 --> T03[T03: 前端API+Store+新页面]
|
||||
T02 --> T04[T04: 前端现有页面改造]
|
||||
T03 --> T04
|
||||
T04 --> T05[T05: 集成联调+边界处理]
|
||||
```
|
||||
919
docs/arch-iteration-v2.md
Normal file
919
docs/arch-iteration-v2.md
Normal file
@@ -0,0 +1,919 @@
|
||||
# 小菜记账 — 全量迭代架构设计 v2
|
||||
|
||||
> 版本: v2.0 | 日期: 2026-06-10
|
||||
> 架构师: 高见远(Gao)
|
||||
> 基线: PRD v2.0(35 项改进点,6 个维度)
|
||||
> 技术栈: Uni-app + Vue 3 + Pinia + SCSS (前端) | Node.js + Express + MySQL 8.0 (后端)
|
||||
|
||||
---
|
||||
|
||||
## 一、实现方案 + 技术选型
|
||||
|
||||
### 1.1 安全修复方案
|
||||
|
||||
#### S1:.env 凭据泄露修复
|
||||
|
||||
| 项目 | 方案 |
|
||||
|------|------|
|
||||
| 核心思路 | 凭据轮换 + Git 历史重写 + pre-commit 防护 |
|
||||
| 工具 | `git-filter-repo`(Python,比 BFG 更安全,支持 blob 回调替换) |
|
||||
| 防护 | `.githooks/pre-commit` 拦截 `.env` 文件提交 |
|
||||
| 回滚 | 操作前 `git clone --mirror` 完整备份 |
|
||||
| 执行顺序 | ① 轮换所有凭据 → ② 重写 Git 历史 → ③ 强制推送 → ④ 验证 .gitignore |
|
||||
|
||||
#### S2:logs.ts SQL 拼接修复
|
||||
|
||||
| 项目 | 方案 |
|
||||
|------|------|
|
||||
| 当前问题 | `LIMIT ${pSize} OFFSET ${offset}` 模板字符串直接拼接 |
|
||||
| 修复方案 | `parseInt` + 范围校验后使用 `pool.query` + `?` 占位符 |
|
||||
| 关键点 | `pool.query` 支持 `?` 占位符用于整数参数;`pool.execute` 的 prepared statement 对 LIMIT/OFFSET 类型要求严格,故选用 `pool.query` |
|
||||
|
||||
#### S3:TOKEN_SECRET 弱默认值 + 命名不一致
|
||||
|
||||
| 项目 | 方案 |
|
||||
|------|------|
|
||||
| 核心思路 | 新增 `server/src/config/token.ts` 集中管理,移除所有 fallback |
|
||||
| 命名统一 | `backup.ts` 中 `JWT_SECRET` → `TOKEN_SECRET`;所有引用改为 `import { TOKEN_SECRET } from '../config/token'` |
|
||||
| 启动校验 | `TOKEN_SECRET` 未设置或长度 < 32 时 `process.exit(1)` |
|
||||
| 密钥生成 | `openssl rand -hex 32` 生成 64 字符强密钥 |
|
||||
|
||||
#### S4:Export/Backup 短期一次性下载凭证
|
||||
|
||||
| 项目 | 方案 |
|
||||
|------|------|
|
||||
| 核心思路 | 新增 `download_tokens` 表,生成随机凭证(`crypto.randomBytes(32)`) |
|
||||
| 有效期 | 5 分钟,一次性使用后标记 `used_at` |
|
||||
| 存储 | MySQL 表(写入频率极低,无需 Redis) |
|
||||
| 清理 | 健康检查时附带清理 `expires_at < NOW()` 的记录 |
|
||||
| 改动 | `backup.ts` 和 `export.ts` 的下载逻辑改为先获取凭证再用凭证下载 |
|
||||
|
||||
#### S5:Refresh Token 机制
|
||||
|
||||
| 项目 | 方案 |
|
||||
|------|------|
|
||||
| 架构 | 双 Token 机制:Access Token(2h)+ Refresh Token(30d) |
|
||||
| 存储 | `refresh_tokens` 表,仅存储 SHA-256 hash(不存明文) |
|
||||
| 轮换 | 每次刷新后旧 Refresh Token 立即失效(`revoked_at`),返回新 Token |
|
||||
| 前端适配 | `request.ts` 401 → 尝试 refresh → 成功则重试原请求 → refresh 也失败则清 token 跳登录 |
|
||||
| 安全 | 修改密码时撤销所有 Refresh Token;单用户最多 5 个有效 Refresh Token |
|
||||
| 优势 | 保持现有 HMAC-SHA256 签名方式不变,仅缩短 Access Token 有效期 |
|
||||
|
||||
#### S6:Admin 硬删除 → 软删除 + 二次确认
|
||||
|
||||
| 项目 | 方案 |
|
||||
|------|------|
|
||||
| DB 变更 | `users` 表新增 `deleted_at DATETIME DEFAULT NULL` + 索引 |
|
||||
| 删除操作 | `UPDATE users SET deleted_at = NOW() WHERE id = ?` |
|
||||
| 恢复接口 | `POST /api/admin/users/:id/restore` → `SET deleted_at = NULL` |
|
||||
| 全局过滤 | 所有涉及 `users` 表的查询增加 `WHERE deleted_at IS NULL` |
|
||||
| 前端确认 | 管理页点击删除 → 弹窗二次确认 → 调用软删除接口 |
|
||||
|
||||
#### S7:DB 连接移除默认凭据
|
||||
|
||||
| 项目 | 方案 |
|
||||
|------|------|
|
||||
| 修复 | 移除所有 `|| 'localhost'`、`|| 'xiaocai'`、`|| 'xiaocai123'` fallback |
|
||||
| 启动校验 | 遍历必需环境变量,缺失任一即 `process.exit(1)` |
|
||||
|
||||
### 1.2 通用组件抽取方案
|
||||
|
||||
#### ManageList 组件
|
||||
|
||||
```
|
||||
client/src/components/ManageList/ManageList.vue
|
||||
|
||||
Props:
|
||||
- items: T[] // 列表数据
|
||||
- displayField: string // 显示字段名
|
||||
- colorField?: string // 颜色字段名(标签用)
|
||||
- iconField?: string // 图标字段名(分类用)
|
||||
- showDrag?: boolean // 是否显示拖拽排序
|
||||
- showEdit?: boolean // 是否显示编辑按钮
|
||||
- showDelete?: boolean // 是否显示删除按钮
|
||||
- emptyText?: string // 空状态文案
|
||||
|
||||
Emits:
|
||||
- add()
|
||||
- edit(item: T)
|
||||
- delete(item: T)
|
||||
- sort(ids: number[])
|
||||
|
||||
Slots:
|
||||
- item-icon="{ item }" // 自定义图标区域(分类用 CategoryIcon)
|
||||
- item-badge="{ item }" // 自定义标签区域
|
||||
```
|
||||
|
||||
#### ColorPicker 组件
|
||||
|
||||
```
|
||||
client/src/components/ColorPicker/ColorPicker.vue
|
||||
|
||||
Props:
|
||||
- modelValue: string // 当前选中颜色
|
||||
- colors?: string[] // 可选颜色列表(默认 8 色)
|
||||
|
||||
Emits:
|
||||
- update:modelValue(color: string)
|
||||
```
|
||||
|
||||
#### EditModal 组件
|
||||
|
||||
```
|
||||
client/src/components/EditModal/EditModal.vue
|
||||
|
||||
Props:
|
||||
- visible: boolean
|
||||
- title: string
|
||||
- fields: EditField[] // 动态字段配置
|
||||
- modelValue: Record<string, any>
|
||||
|
||||
Emits:
|
||||
- update:visible(val: boolean)
|
||||
- confirm(data: Record<string, any>)
|
||||
|
||||
interface EditField {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'color'
|
||||
placeholder?: string
|
||||
maxlength?: number
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 乐观更新实现模式
|
||||
|
||||
```
|
||||
模式:先写本地 state,再异步调接口,失败则回滚
|
||||
|
||||
async function addCategory(data) {
|
||||
// 1. 生成临时 ID(负数,避免与真实 ID 冲突)
|
||||
const tempId = -Date.now()
|
||||
const optimisticItem = { id: tempId, ...data }
|
||||
|
||||
// 2. 立即更新本地 state
|
||||
categories.value.push(optimisticItem)
|
||||
|
||||
try {
|
||||
// 3. 调用 API
|
||||
const result = await api.createCategory(data)
|
||||
|
||||
// 4. 用真实 ID 替换临时 ID
|
||||
const idx = categories.value.findIndex(c => c.id === tempId)
|
||||
if (idx !== -1) categories.value[idx].id = result.id
|
||||
} catch (err) {
|
||||
// 5. 回滚:移除临时项
|
||||
categories.value = categories.value.filter(c => c.id !== tempId)
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
适用范围:
|
||||
✅ 新增分类/标签、编辑分类/标签、拖拽排序
|
||||
❌ 删除操作仍等接口确认(防误删)
|
||||
```
|
||||
|
||||
### 1.4 Refresh Token 实现方案
|
||||
|
||||
```
|
||||
┌──────────┐ POST /auth/login ┌──────────┐
|
||||
│ Client │ ──────────────────────────>│ Server │
|
||||
│ │<──────────────────────────│ │
|
||||
│ │ { accessToken, refreshToken } │
|
||||
│ │ │ │
|
||||
│ │ GET /api/xxx │ │
|
||||
│ │ Header: Bearer <accessToken> │
|
||||
│ │ ──────────────────────────>│ │
|
||||
│ │<──────────────────────────│ │
|
||||
│ │ 200 OK │ │
|
||||
│ │ │ │
|
||||
│ │ GET /api/xxx (token 过期) │ │
|
||||
│ │ ──────────────────────────>│ │
|
||||
│ │<──────────────────────────│ │
|
||||
│ │ 401 { code: 40101 } │ │
|
||||
│ │ │ │
|
||||
│ │ POST /auth/refresh │ │
|
||||
│ │ { refreshToken } │ │
|
||||
│ │ ──────────────────────────>│ │
|
||||
│ │<──────────────────────────│ │
|
||||
│ │ { accessToken, refreshToken } │
|
||||
│ │ │ │
|
||||
│ │ 重试原请求 │ │
|
||||
│ │ ──────────────────────────>│ │
|
||||
└──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
**后端关键实现**:
|
||||
- `auth.ts` 登录成功时:生成 Access Token(2h)+ Refresh Token(randomBytes(32)),存 hash 到 `refresh_tokens` 表
|
||||
- 新增 `POST /auth/refresh`:验证 Refresh Token hash → 撤销旧 Token → 生成新双 Token
|
||||
- `middleware/auth.ts`:Access Token 过期返回 `40101`(区别于无效 Token 的 `40100`)
|
||||
|
||||
**前端关键实现**:
|
||||
- `request.ts`:收到 `40101` → 调用 `/auth/refresh` → 成功则重试 → 失败则清 token 跳登录
|
||||
- 存储:`xc:accessToken` + `xc:refreshToken` 分开存储
|
||||
- 并发请求时只触发一次 refresh,其他请求排队等待
|
||||
|
||||
### 1.5 软删除方案
|
||||
|
||||
```
|
||||
影响范围:
|
||||
- users 表:新增 deleted_at 字段
|
||||
- 所有 JOIN users 的查询:增加 deleted_at IS NULL 过滤
|
||||
- admin.ts:DELETE → UPDATE SET deleted_at
|
||||
- 新增恢复接口
|
||||
- 登录验证:检查 deleted_at IS NULL
|
||||
|
||||
具体改动位置:
|
||||
1. schema.sql / migrate.sql:ALTER TABLE
|
||||
2. auth.ts 登录:WHERE openid = ? AND deleted_at IS NULL
|
||||
3. admin.ts 用户列表:WHERE deleted_at IS NULL(或增加"已禁用"筛选)
|
||||
4. transaction.ts 列表查询:JOIN users 时增加 u.deleted_at IS NULL
|
||||
5. 群组统计/成员列表:增加 deleted_at 过滤
|
||||
```
|
||||
|
||||
### 1.6 iconfont 迁移方案
|
||||
|
||||
```
|
||||
当前状态:Icon.vue 使用 PNG 图片映射
|
||||
迁移方案:
|
||||
1. 在 iconfont.cn 创建项目,上传 SVG 图标
|
||||
2. 生成字体文件(ttf),放入 client/src/static/iconfont/
|
||||
3. 新建 client/src/styles/iconfont.scss,@font-face 声明
|
||||
4. Icon.vue 改为 <text class="iconfont icon-xxx" /> 方式渲染
|
||||
5. 保留 name prop 接口不变,内部映射改为 class 名
|
||||
6. 额外图标(如 lock)直接在 iconfont 项目中添加
|
||||
|
||||
收益:
|
||||
- 包体积减小(字体 < 20KB vs 多个 PNG)
|
||||
- 支持动态颜色(color prop 直接生效)
|
||||
- 新增图标只需上传 SVG,无需切图
|
||||
```
|
||||
|
||||
### 1.7 离线队列方案
|
||||
|
||||
```
|
||||
架构设计:
|
||||
|
||||
1. 离线队列管理器(client/src/utils/offline.ts)
|
||||
- pendingOps: 存储于 uni.setStorageSync('xc:pendingOps')
|
||||
- 数据结构: Array<{ id: string, type: 'create', data: object, createdAt: number, status: 'pending' | 'failed' }>
|
||||
- MVP 仅支持 create(新增记账)
|
||||
|
||||
2. 网络状态监听
|
||||
- App.vue onLaunch 中注册 uni.onNetworkStatusChange
|
||||
- 网络恢复时自动调用 processPendingOps()
|
||||
|
||||
3. 操作流程
|
||||
- 有网络:正常调接口
|
||||
- 无网络:
|
||||
├── 写入 pendingOps
|
||||
├── 本地 state 立即更新(乐观更新 + "待同步"标记)
|
||||
└── Toast "已保存,将在网络恢复后同步"
|
||||
|
||||
4. 同步流程
|
||||
- 按时间顺序逐条执行
|
||||
- 全部成功:清除队列 + toast "同步完成"
|
||||
- 部分失败:标记失败项 + 保留在队列 + toast "N 条同步失败"
|
||||
|
||||
5. 网络检测
|
||||
- uni.getNetworkType() 获取当前网络状态
|
||||
- 封装 isOnline(): boolean 工具函数
|
||||
```
|
||||
|
||||
### 1.8 财务报告推送方案
|
||||
|
||||
```
|
||||
架构设计:
|
||||
|
||||
1. 定时任务(server/src/services/scheduler.ts)
|
||||
- 使用 node-cron 实现
|
||||
- 每周一 09:00:生成上周收支摘要 → 推送
|
||||
- 每月 1 号 09:00:生成上月收支报告 → 推送
|
||||
|
||||
2. 报告生成(server/src/services/report.ts)
|
||||
- 查询指定时间范围的收支数据
|
||||
- 生成摘要文本(总支出/收入、Top 分类、日均消费等)
|
||||
|
||||
3. 微信订阅消息(server/src/services/wechat-subscribe.ts)
|
||||
- 调用微信 subscribeMessage.send 接口
|
||||
- 模板 ID 需在微信公众平台申请
|
||||
- 用户需先授权订阅(一次性授权,每次推送需用户主动触发授权)
|
||||
|
||||
4. 降级策略
|
||||
- 优先站内通知(现有 notifications 系统)
|
||||
- 微信订阅消息作为增强项
|
||||
- 如果模板审核不通过,仅保留站内通知
|
||||
|
||||
5. 用户设置
|
||||
- user_settings 表存储推送开关
|
||||
- 客户端设置页增加"财务报告推送"开关
|
||||
```
|
||||
|
||||
### 1.9 预算预警方案
|
||||
|
||||
```
|
||||
架构设计:
|
||||
|
||||
1. 双重检查策略
|
||||
- 实时检查:记账后立即检查预算(精准、低开销)
|
||||
- 每日全量扫描:凌晨定时扫描所有用户预算(覆盖周期记账等非手动场景)
|
||||
|
||||
2. 预警等级
|
||||
- 80%:站内通知(type: 'personal', is_urgent: false)
|
||||
- 100%:站内通知 + 微信订阅消息
|
||||
- 120%:站内紧急通知(is_urgent: true)+ 微信订阅消息
|
||||
|
||||
3. 去重机制
|
||||
- 同一用户同一月同一等级只推送一次
|
||||
- budget_alerts 表记录已推送的预警(user_id + month + level → UNIQUE)
|
||||
|
||||
4. 触发点
|
||||
- transaction.ts POST 创建记账后 → 调用 checkBudgetAlert(userId, month)
|
||||
- scheduler.ts 每日扫描 → 批量 checkBudgetAlert
|
||||
```
|
||||
|
||||
### 1.10 测试框架选型
|
||||
|
||||
| 层级 | 框架 | 理由 |
|
||||
|------|------|------|
|
||||
| 后端集成测试 | Jest + supertest | Express 生态标配;supertest 无需启动真实服务器;与 ts-jest 配合良好 |
|
||||
| 前端 Store 测试 | Vitest + @pinia/testing | Vitest 与 Vite 原生集成(Uni-app 基于 Vite);@pinia/testing 提供 createTestingPinia() |
|
||||
| 工具函数测试 | Jest(后端)+ Vitest(前端) | 纯函数测试最简单,适合作为测试练手 |
|
||||
| API 契约测试 | Jest + ajv(JSON Schema) | 轻量级方案,不需要引入完整的契约测试框架 |
|
||||
|
||||
---
|
||||
|
||||
## 二、文件列表及相对路径
|
||||
|
||||
### 2.1 新建文件
|
||||
|
||||
| 文件路径 | 用途 |
|
||||
|----------|------|
|
||||
| `server/src/config/token.ts` | TOKEN_SECRET 集中配置与启动校验 |
|
||||
| `server/src/services/scheduler.ts` | 定时任务调度(node-cron) |
|
||||
| `server/src/services/report.ts` | 财务报告生成逻辑 |
|
||||
| `server/src/services/budget-alert.ts` | 预算预警检查与推送逻辑 |
|
||||
| `server/src/services/wechat-subscribe.ts` | 微信订阅消息发送封装 |
|
||||
| `server/src/utils/upload.ts` | 公共上传处理函数(C3 抽取) |
|
||||
| `server/src/routes/report.ts` | 财务报告 API 路由 |
|
||||
| `server/tests/setup.ts` | 测试数据库初始化与清理 |
|
||||
| `server/tests/auth.test.ts` | 认证路由集成测试 |
|
||||
| `server/tests/transaction.test.ts` | 交易路由集成测试 |
|
||||
| `server/tests/category.test.ts` | 分类路由集成测试 |
|
||||
| `server/tests/budget.test.ts` | 预算路由集成测试 |
|
||||
| `server/tests/stats.test.ts` | 统计路由集成测试 |
|
||||
| `server/jest.config.js` | Jest 配置 |
|
||||
| `server/.env.test` | 测试数据库环境变量 |
|
||||
| `client/src/components/ManageList/ManageList.vue` | 通用管理列表组件 |
|
||||
| `client/src/components/ColorPicker/ColorPicker.vue` | 颜色选择器组件 |
|
||||
| `client/src/components/EditModal/EditModal.vue` | 通用编辑弹窗组件 |
|
||||
| `client/src/components/Snackbar/Snackbar.vue` | 删除撤销 Snackbar 组件 |
|
||||
| `client/src/utils/offline.ts` | 离线队列管理器 |
|
||||
| `client/src/utils/avatar.ts` | 头像 URL 拼接工具函数(C9) |
|
||||
| `client/src/static/iconfont/` | iconfont 字体文件目录 |
|
||||
| `client/src/styles/iconfont.scss` | iconfont 样式声明 |
|
||||
| `client/tests/stores/transaction.test.ts` | 交易 Store 单元测试 |
|
||||
| `client/tests/stores/category.test.ts` | 分类 Store 单元测试 |
|
||||
| `client/tests/utils/format.test.ts` | 格式化工具函数测试 |
|
||||
| `client/tests/utils/app-ready.test.ts` | app-ready 工具函数测试 |
|
||||
| `client/vitest.config.ts` | Vitest 配置 |
|
||||
| `.githooks/pre-commit` | 防止 .env 提交的 Git 钩子 |
|
||||
|
||||
### 2.2 修改文件
|
||||
|
||||
| 文件路径 | 主要变更 |
|
||||
|----------|----------|
|
||||
| **后端 - 安全** | |
|
||||
| `server/src/db/connection.ts` | 移除默认凭据 fallback,增加启动校验 |
|
||||
| `server/src/middleware/auth.ts` | 引用 config/token.ts;Access Token 有效期缩短为 2h;区分 40100/40101 错误码 |
|
||||
| `server/src/routes/auth.ts` | 引用 config/token.ts;实现双 Token 机制;新增 POST /refresh 接口 |
|
||||
| `server/src/routes/backup.ts` | JWT_SECRET → TOKEN_SECRET;下载改用 download_tokens 凭证 |
|
||||
| `server/src/routes/admin.ts` | 硬删除 → 软删除;新增恢复接口;N+1 优化 |
|
||||
| `server/src/routes/logs.ts` | SQL 拼接改为参数化查询 |
|
||||
| `server/src/routes/export.ts` | 下载改用 download_tokens 凭证 |
|
||||
| **后端 - 代码质量** | |
|
||||
| `server/src/routes/recurring.ts` | sync 接口增加事务包裹 |
|
||||
| `server/src/routes/user.ts` | 上传逻辑抽取使用公共函数 |
|
||||
| `server/src/routes/notification.ts` | 上传逻辑抽取使用公共函数 |
|
||||
| **后端 - 性能** | |
|
||||
| `server/src/routes/stats.ts` | 新增 GET /dashboard 聚合接口 |
|
||||
| `server/src/routes/track.ts` | 逐条 INSERT → 批量 INSERT |
|
||||
| `server/src/routes/budget.ts` | 支持 group_id 参数;记账后触发预算预警 |
|
||||
| `server/src/routes/transaction.ts` | 创建记账后触发预算预警检查 |
|
||||
| **后端 - DB** | |
|
||||
| `server/src/db/schema.sql` | 新增 download_tokens、refresh_tokens、budget_alerts、user_settings 表;users 增加 deleted_at |
|
||||
| `server/src/db/migrate.sql` | 对应迁移脚本 |
|
||||
| **后端 - 入口** | |
|
||||
| `server/src/index.ts` | 移除 TOKEN_SECRET 警告;注册 report 路由;初始化 scheduler |
|
||||
| **前端 - 代码质量** | |
|
||||
| `client/src/stores/category.ts` | 乐观更新;try/catch;缓存标记 lastFetchTime |
|
||||
| `client/src/stores/tag.ts` | 乐观更新;try/catch;缓存标记 lastFetchTime |
|
||||
| `client/src/stores/transaction.ts` | 删除撤销;try/catch |
|
||||
| `client/src/stores/budget.ts` | try/catch;支持 group_id |
|
||||
| `client/src/stores/notification.ts` | 未读计数缓存 30s |
|
||||
| `client/src/stores/group.ts` | refreshAll 并行加载 |
|
||||
| `client/src/stores/stats.ts` | 使用 dashboard 聚合接口 |
|
||||
| `client/src/stores/user.ts` | Refresh Token 适配 |
|
||||
| `client/src/utils/format.ts` | 去重(getCurrentMonth 已在后端存在) |
|
||||
| `client/src/utils/request.ts` | Refresh Token 重试逻辑 |
|
||||
| `client/src/utils/app-ready.ts` | 超时 reject(非静默 resolve) |
|
||||
| `client/src/utils/tracker.ts` | 攒批发送(500ms 或 10 条) |
|
||||
| `client/src/config.ts` | 已集中化,搜索残留硬编码 URL |
|
||||
| `client/src/components/Icon/Icon.vue` | PNG → iconfont 字体图标 |
|
||||
| `client/src/components/TransactionItem/TransactionItem.vue` | 群组只读标记 |
|
||||
| `client/src/components/ChartWrapper/` | 延迟渲染(IntersectionObserver) |
|
||||
| **前端 - 页面** | |
|
||||
| `client/src/pages/category-manage/index.vue` | 使用 ManageList/ColorPicker/EditModal 组件 |
|
||||
| `client/src/pages/tag-manage/index.vue` | 使用 ManageList/ColorPicker/EditModal 组件 |
|
||||
| `client/src/pages/bills/index.vue` | 删除撤销 Snackbar |
|
||||
| `client/src/pages/budget/index.vue` | 群组预算视图 |
|
||||
| `client/src/pages/stats/index.vue` | 使用 dashboard 接口 |
|
||||
| `client/src/pages/add/index.vue` | 离线记账支持 |
|
||||
| `client/src/pages.json` | 全局 enablePullDownRefresh |
|
||||
| `client/src/App.vue` | 注册网络状态监听 |
|
||||
| **配置** | |
|
||||
| `server/package.json` | 新增 jest、ts-jest、supertest、node-cron 依赖 |
|
||||
| `client/package.json` | 新增 vitest、@pinia/testing、@vue/test-utils 依赖 |
|
||||
|
||||
---
|
||||
|
||||
## 三、数据结构和接口
|
||||
|
||||
### 3.1 新增/修改的数据库表结构
|
||||
|
||||
#### 新增表
|
||||
|
||||
```sql
|
||||
-- 一次性下载凭证表(S4)
|
||||
CREATE TABLE IF NOT EXISTS download_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
token VARCHAR(64) NOT NULL UNIQUE COMMENT '随机凭证',
|
||||
user_id INT NOT NULL,
|
||||
resource_type ENUM('backup', 'export') NOT NULL COMMENT '资源类型',
|
||||
resource_id VARCHAR(100) DEFAULT '' COMMENT '资源标识(备份名等)',
|
||||
expires_at DATETIME NOT NULL COMMENT '过期时间',
|
||||
used_at DATETIME DEFAULT NULL COMMENT '使用时间(NULL=未使用)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_token (token),
|
||||
INDEX idx_expires (expires_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Refresh Token 表(S5)
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
|
||||
expires_at DATETIME NOT NULL COMMENT '过期时间',
|
||||
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间(NULL=有效)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_hash (token_hash),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 预算预警记录表(F-2,去重用)
|
||||
CREATE TABLE IF NOT EXISTS budget_alerts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
month VARCHAR(7) NOT NULL COMMENT '格式:2026-05',
|
||||
level ENUM('80', '100', '120') NOT NULL COMMENT '预警等级',
|
||||
group_id INT DEFAULT NULL COMMENT '群组ID(NULL=个人)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_month_level (user_id, month, level, group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 用户设置表(F-1 报告推送开关等)
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INT PRIMARY KEY,
|
||||
report_push_enabled TINYINT(1) DEFAULT 1 COMMENT '是否开启财务报告推送',
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
#### 修改表
|
||||
|
||||
```sql
|
||||
-- users 表:新增软删除字段(S6)
|
||||
ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间(NULL=正常)';
|
||||
CREATE INDEX idx_users_deleted ON users(deleted_at);
|
||||
|
||||
-- budgets 表:新增 group_id 支持(UX-4)
|
||||
ALTER TABLE budgets DROP INDEX uk_user_month;
|
||||
ALTER TABLE budgets ADD COLUMN group_id INT DEFAULT NULL COMMENT '群组ID(NULL=个人预算)';
|
||||
ALTER TABLE budgets ADD UNIQUE KEY uk_user_group_month (user_id, group_id, month);
|
||||
ALTER TABLE budgets ADD INDEX idx_group_month (group_id, month);
|
||||
```
|
||||
|
||||
### 3.2 新增/修改的 API 端点定义
|
||||
|
||||
#### 新增端点
|
||||
|
||||
| 方法 | 路径 | 说明 | 请求体 / 参数 | 响应 |
|
||||
|------|------|------|---------------|------|
|
||||
| POST | `/api/auth/refresh` | 刷新 Access Token | `{ refreshToken: string }` | `{ code: 0, data: { accessToken, refreshToken, expiresIn } }` |
|
||||
| GET | `/api/stats/dashboard` | 聚合统计(一次返回 overview + category + trend) | `?month=2026-06&type=expense&period=month&group_id=null` | `{ code: 0, data: { overview, category, trend } }` |
|
||||
| POST | `/api/admin/users/:id/restore` | 恢复软删除用户 | — | `{ code: 0 }` |
|
||||
| POST | `/api/download-tokens` | 生成下载凭证 | `{ resourceType: 'backup'|'export', resourceId: string }` | `{ code: 0, data: { token, downloadUrl } }` |
|
||||
| GET | `/api/download/:token` | 使用凭证下载文件 | `:token` 路径参数 | 文件流或 403 |
|
||||
| GET | `/api/reports/weekly` | 获取上周报告 | — | `{ code: 0, data: { startDate, endDate, expense, income, topCategories, dailyAvg } }` |
|
||||
| GET | `/api/reports/monthly` | 获取上月报告 | — | `{ code: 0, data: { month, expense, income, topCategories, dailyAvg, monthOverMonth } }` |
|
||||
| GET | `/api/user/settings` | 获取用户设置 | — | `{ code: 0, data: { reportPushEnabled, ... } }` |
|
||||
| PUT | `/api/user/settings` | 更新用户设置 | `{ reportPushEnabled?: boolean }` | `{ code: 0 }` |
|
||||
|
||||
#### 修改端点
|
||||
|
||||
| 方法 | 路径 | 变更说明 |
|
||||
|------|------|----------|
|
||||
| POST | `/api/auth/login` | 返回值新增 `refreshToken` 和 `expiresIn` 字段 |
|
||||
| POST | `/api/auth/demo-login` | 同上 |
|
||||
| DELETE | `/api/admin/users/:id` | 改为软删除(`SET deleted_at = NOW()`) |
|
||||
| GET | `/api/budget` | 支持 `group_id` 参数;群组视图返回 `groupTotal` + `myAmount` |
|
||||
| POST | `/api/budget` | 支持 `group_id` 参数 |
|
||||
| POST | `/api/transactions` | 创建后触发预算预警检查 |
|
||||
| POST | `/api/track` | 支持批量 `events[]` 数组,后端批量 INSERT |
|
||||
| GET | `/api/backup/:id/download` | 改用 download_tokens 凭证 |
|
||||
| GET | `/api/export` | 改用 download_tokens 凭证(GET 带 token 参数改为先获取凭证再下载) |
|
||||
|
||||
---
|
||||
|
||||
## 四、程序调用流图
|
||||
|
||||
### 4.1 Refresh Token 刷新流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client (request.ts)
|
||||
participant S as Server (auth.ts)
|
||||
participant M as Server (auth middleware)
|
||||
participant DB as MySQL (refresh_tokens)
|
||||
|
||||
Note over C,S: 1. 正常登录
|
||||
C->>S: POST /auth/login { code }
|
||||
S->>DB: INSERT refresh_tokens (user_id, token_hash, expires_at)
|
||||
S-->>C: { accessToken(2h), refreshToken(30d), userId }
|
||||
|
||||
Note over C,S: 2. API 调用(Access Token 有效)
|
||||
C->>M: GET /api/xxx Authorization: Bearer <accessToken>
|
||||
M->>M: 验证 HMAC 签名 + 有效期
|
||||
M-->>C: 200 OK { data }
|
||||
|
||||
Note over C,S: 3. Access Token 过期
|
||||
C->>M: GET /api/xxx Authorization: Bearer <expiredToken>
|
||||
M->>M: 签名有效但已过期
|
||||
M-->>C: 401 { code: 40101, message: 'token已过期' }
|
||||
|
||||
Note over C,S: 4. 自动刷新
|
||||
C->>S: POST /auth/refresh { refreshToken }
|
||||
S->>DB: SELECT WHERE token_hash=SHA256(refreshToken) AND revoked_at IS NULL AND expires_at>NOW()
|
||||
DB-->>S: 找到记录
|
||||
S->>DB: UPDATE refresh_tokens SET revoked_at=NOW() WHERE id=?
|
||||
S->>DB: INSERT refresh_tokens (新 token_hash)
|
||||
S-->>C: { accessToken, refreshToken, expiresIn }
|
||||
C->>C: 存储新 Token,重试原请求
|
||||
```
|
||||
|
||||
### 4.2 删除撤销流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant P as BillsPage
|
||||
participant S as TransactionStore
|
||||
participant SN as Snackbar
|
||||
participant API as Server API
|
||||
|
||||
U->>P: 左滑删除记录
|
||||
P->>S: pendingDelete(id, item, index)
|
||||
S->>S: 从 transactions 列表移除
|
||||
S->>SN: 显示 Snackbar "已删除 1 条记录 [撤销]"
|
||||
SN-->>P: 3秒倒计时
|
||||
|
||||
alt 3秒内点击撤销
|
||||
U->>SN: 点击 [撤销]
|
||||
SN->>S: cancelDelete(id)
|
||||
S->>S: 恢复到列表原位置
|
||||
SN->>SN: 消失
|
||||
else 3秒超时
|
||||
SN->>S: confirmDelete(id)
|
||||
S->>API: DELETE /api/transactions/:id
|
||||
alt 删除成功
|
||||
API-->>S: 200 OK
|
||||
S->>S: 清除 pendingDelete 记录
|
||||
else 删除失败
|
||||
API-->>S: 500 Error
|
||||
S->>S: 恢复到列表原位置
|
||||
S->>U: toast "删除失败"
|
||||
end
|
||||
SN->>SN: 消失
|
||||
end
|
||||
```
|
||||
|
||||
### 4.3 预算预警触发流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant T as Transaction Route
|
||||
participant BA as BudgetAlert Service
|
||||
participant DB as MySQL
|
||||
participant NS as Notification System
|
||||
participant WX as WeChat API
|
||||
|
||||
C->>T: POST /api/transactions { amount, type, date }
|
||||
T->>DB: INSERT INTO transactions
|
||||
T->>BA: checkBudgetAlert(userId, month)
|
||||
BA->>DB: SELECT budget FROM budgets WHERE user_id=? AND month=?
|
||||
BA->>DB: SELECT SUM(amount) FROM transactions WHERE user_id=? AND month=? AND type='expense'
|
||||
|
||||
alt 支出/预算 >= 80% 且未推送过
|
||||
BA->>DB: INSERT budget_alerts (level='80')
|
||||
BA->>NS: 创建站内通知
|
||||
NS->>DB: INSERT INTO notifications
|
||||
else 支出/预算 >= 100%
|
||||
BA->>DB: INSERT budget_alerts (level='100')
|
||||
BA->>NS: 创建站内通知
|
||||
BA->>WX: 发送微信订阅消息
|
||||
else 支出/预算 >= 120%
|
||||
BA->>DB: INSERT budget_alerts (level='120')
|
||||
BA->>NS: 创建紧急站内通知 (is_urgent=true)
|
||||
BA->>WX: 发送微信订阅消息
|
||||
end
|
||||
|
||||
T-->>C: { code: 0, data: { id } }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、任务列表
|
||||
|
||||
> 按批次分组,每批次包含 1 个任务,共 5 个任务
|
||||
> 优先级:P0(阻塞上线)> P1(必须有)> P2(锦上添花)
|
||||
|
||||
### T01: 项目基础设施与安全修复
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **任务 ID** | T01 |
|
||||
| **优先级** | P0 |
|
||||
| **描述** | 搭建项目基础设施(依赖安装、配置文件、入口文件、数据库迁移),并完成所有 P0/P1 安全修复(S1-S7) |
|
||||
| **依赖** | 无 |
|
||||
| **涉及文件** | **新建**:`server/src/config/token.ts`, `.githooks/pre-commit`, `server/.env.test`, `server/jest.config.js`, `client/vitest.config.ts` |
|
||||
| | **修改**:`server/package.json`, `client/package.json`, `server/src/db/schema.sql`, `server/src/db/migrate.sql`, `server/src/db/connection.ts`, `server/src/middleware/auth.ts`, `server/src/routes/auth.ts`, `server/src/routes/backup.ts`, `server/src/routes/admin.ts`, `server/src/routes/logs.ts`, `server/src/routes/export.ts`, `server/src/index.ts`, `client/src/pages.json` |
|
||||
|
||||
**详细子项**:
|
||||
|
||||
1. **S1 .env 凭据泄露修复**:安装 git-filter-repo、重写历史、添加 pre-commit 钩子
|
||||
2. **S2 SQL 参数化**:`logs.ts:167` LIMIT/OFFSET 改为 `?` 占位符 + parseInt 校验
|
||||
3. **S3 TOKEN_SECRET 统一**:新建 `config/token.ts`,统一命名 + 强制启动校验 + 生成强密钥
|
||||
4. **S4 下载凭证**:新建 `download_tokens` 表,改造 backup.ts / export.ts 下载逻辑
|
||||
5. **S5 Refresh Token**:新建 `refresh_tokens` 表,双 Token 机制(Access 2h + Refresh 30d),前端 request.ts 401 自动刷新
|
||||
6. **S6 软删除**:`users` 表新增 `deleted_at`,admin.ts DELETE → UPDATE,新增恢复接口,全局查询过滤
|
||||
7. **S7 DB 连接校验**:connection.ts 移除 fallback,启动时校验必需环境变量
|
||||
8. **基础设施**:package.json 新增依赖(jest, supertest, node-cron, vitest 等),jest/vitest 配置,.env.test
|
||||
|
||||
### T02: 代码质量重构
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **任务 ID** | T02 |
|
||||
| **优先级** | P1 |
|
||||
| **描述** | 抽取通用组件消除重复代码(ManageList/ColorPicker/EditModal),补齐事务与异常处理,统一工具函数与硬编码 URL |
|
||||
| **依赖** | T01 |
|
||||
| **涉及文件** | **新建**:`client/src/components/ManageList/ManageList.vue`, `client/src/components/ColorPicker/ColorPicker.vue`, `client/src/components/EditModal/EditModal.vue`, `server/src/utils/upload.ts`, `client/src/utils/avatar.ts`, `client/src/static/iconfont/*`, `client/src/styles/iconfont.scss` |
|
||||
| | **修改**:`client/src/pages/category-manage/index.vue`, `client/src/pages/tag-manage/index.vue`, `client/src/components/Icon/Icon.vue`, `server/src/routes/recurring.ts`, `server/src/routes/user.ts`, `server/src/routes/notification.ts`, `client/src/utils/format.ts`, `client/src/config.ts` |
|
||||
|
||||
**详细子项**:
|
||||
|
||||
1. **C2 ManageList 抽取**:新建 ManageList 组件,category-manage 和 tag-manage 改为使用该组件,统一行为
|
||||
2. **C2 ColorPicker 抽取**:从两个管理页面的弹窗中抽取颜色选择器
|
||||
3. **C2 EditModal 抽取**:从两个管理页面的弹窗中抽取编辑弹窗
|
||||
4. **C4 Recurring 事务**:`recurring.ts /sync` 接口用 `getConnection() + beginTransaction()` 包裹
|
||||
5. **C3 上传逻辑抽取**:`user.ts` 和 `notification.ts` 的 multer 配置抽取为 `utils/upload.ts`
|
||||
6. **C1 工具函数去重**:前端 `format.ts` 的 `getCurrentMonth` 与后端 `date.ts` 重复,前端保留并标注来源
|
||||
7. **C7 硬编码 URL**:全局搜索 `http://` 和 `xiaocai.j35.site`,统一从 `config.ts` 读取
|
||||
8. **C8 Icon → iconfont**:生成字体图标,Icon.vue 改为字体渲染
|
||||
9. **C9 头像 URL 拼接**:抽取 `getAvatarUrl(filename)` 工具函数
|
||||
|
||||
### T03: 性能优化与 UX 增强
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **任务 ID** | T03 |
|
||||
| **优先级** | P1 |
|
||||
| **描述** | 实现乐观更新、并行加载、聚合接口、删除撤销、群组只读、离线记账、下拉刷新等性能与体验提升 |
|
||||
| **依赖** | T01 |
|
||||
| **涉及文件** | **新建**:`client/src/components/Snackbar/Snackbar.vue`, `client/src/utils/offline.ts` |
|
||||
| | **修改**:`client/src/stores/category.ts`, `client/src/stores/tag.ts`, `client/src/stores/group.ts`, `client/src/stores/stats.ts`, `client/src/stores/notification.ts`, `client/src/stores/transaction.ts`, `client/src/stores/budget.ts`, `client/src/utils/request.ts`, `client/src/utils/app-ready.ts`, `client/src/utils/tracker.ts`, `client/src/components/TransactionItem/TransactionItem.vue`, `client/src/components/ChartWrapper/`, `client/src/pages/bills/index.vue`, `client/src/pages/budget/index.vue`, `client/src/pages/stats/index.vue`, `client/src/pages/add/index.vue`, `client/src/App.vue`, `server/src/routes/stats.ts`, `server/src/routes/track.ts`, `server/src/routes/budget.ts` |
|
||||
|
||||
**详细子项**:
|
||||
|
||||
1. **Perf-1 乐观更新**:category/tag Store 新增/编辑/排序操作先更新本地 state 再调接口,失败回滚
|
||||
2. **Perf-2 并行加载**:groupStore `refreshAll()` 中无依赖的 `fetchCategories()` + `fetchTags()` 改为 `Promise.all()`
|
||||
3. **Perf-3 聚合接口**:后端 `GET /stats/dashboard` 用 `Promise.all` 并行查询 overview + category + trend
|
||||
4. **Perf-4 批量 INSERT**:前端 tracker.ts 攒批(500ms/10条),后端 track.ts 接收数组批量 INSERT
|
||||
5. **Perf-5 Admin N+1**:admin.ts 用户列表改为 LEFT JOIN 单次查询
|
||||
6. **Perf-6 前端缓存**:category/tag Store 增加 `lastFetchTime`,5 分钟内 onShow 跳过请求
|
||||
7. **Perf-7 未读计数缓存**:notification Store 缓存 30s
|
||||
8. **UX-1 删除撤销**:transaction Store 实现 pendingDelete Map + Snackbar 组件 + 3 秒超时
|
||||
9. **UX-2 群组只读**:TransactionItem 新增 `isReadOnly` prop,非本人记录显示锁图标
|
||||
10. **UX-3 waitForReady 超时**:app-ready.ts 超时后 reject,页面级 catch 显示错误提示
|
||||
11. **UX-4 预算 group_id**:budget Store/页面/路由 支持 group_id 参数
|
||||
12. **UX-5 下拉刷新**:pages.json 全局启用,各页面实现 onPullDownRefresh
|
||||
13. **UX-6 离线记账**:offline.ts 队列管理 + App.vue 网络监听 + add 页面适配
|
||||
14. **UX-7 Loading 态**:异步操作添加 loading ref 守卫 + 按钮禁用
|
||||
|
||||
### T04: 测试体系建设
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **任务 ID** | T04 |
|
||||
| **优先级** | P1 |
|
||||
| **描述** | 搭建前后端测试框架,编写核心路由集成测试与 Store 单元测试,建立持续测试基线 |
|
||||
| **依赖** | T01 |
|
||||
| **涉及文件** | **新建**:`server/tests/setup.ts`, `server/tests/auth.test.ts`, `server/tests/transaction.test.ts`, `server/tests/category.test.ts`, `server/tests/budget.test.ts`, `server/tests/stats.test.ts`, `client/tests/stores/transaction.test.ts`, `client/tests/stores/category.test.ts`, `client/tests/utils/format.test.ts`, `client/tests/utils/app-ready.test.ts` |
|
||||
|
||||
**详细子项**:
|
||||
|
||||
1. **后端测试框架搭建**:jest.config.js + setup.ts(测试数据库初始化与清理)
|
||||
2. **auth 测试**:登录成功/失败、Token 生成与验证、Refresh Token 刷新、无效 Token 拒绝
|
||||
3. **transaction 测试**:CRUD、group_id 过滤、分页、金额精度、权限校验
|
||||
4. **category 测试**:CRUD、默认/自定义分类隔离、排序、迁移
|
||||
5. **budget 测试**:CRUD、月度预算、群组视图
|
||||
6. **stats 测试**:overview/category/trend/dashboard 查询、period 参数校验
|
||||
7. **前端 Store 测试**:transaction/category Store 核心方法测试
|
||||
8. **工具函数测试**:format.ts(formatAmount、formatDate、getCurrentMonth)、app-ready.ts
|
||||
|
||||
### T05: 新功能(财务报告 + 预算预警)
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| **任务 ID** | T05 |
|
||||
| **优先级** | P1 |
|
||||
| **描述** | 实现周/月财务报告推送、预算超支预警、微信订阅消息集成 |
|
||||
| **依赖** | T01 |
|
||||
| **涉及文件** | **新建**:`server/src/services/scheduler.ts`, `server/src/services/report.ts`, `server/src/services/budget-alert.ts`, `server/src/services/wechat-subscribe.ts`, `server/src/routes/report.ts`, `client/src/api/report.ts` |
|
||||
| | **修改**:`server/src/index.ts`, `server/src/routes/transaction.ts`, `server/src/routes/budget.ts` |
|
||||
|
||||
**详细子项**:
|
||||
|
||||
1. **scheduler.ts**:使用 node-cron 注册定时任务(每周一 09:00 推送周报、每月 1 号 09:00 推送月报、每日凌晨全量预算扫描)
|
||||
2. **report.ts**:查询指定时间范围的收支数据,生成摘要文本
|
||||
3. **budget-alert.ts**:实现 checkBudgetAlert 逻辑,80%/100%/120% 分级预警 + 去重
|
||||
4. **wechat-subscribe.ts**:封装微信订阅消息发送接口
|
||||
5. **report 路由**:GET /reports/weekly、GET /reports/monthly
|
||||
6. **用户设置**:GET/PUT /user/settings,report_push_enabled 开关
|
||||
7. **触发集成**:transaction.ts 创建记账后调用 budgetAlert.check();budget.ts 设置预算后调用 budgetAlert.check()
|
||||
|
||||
---
|
||||
|
||||
## 六、任务依赖图
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
T01["T01: 项目基础设施与安全修复<br/>(P0 · S1-S7 + 依赖安装 + DB迁移)"]
|
||||
T02["T02: 代码质量重构<br/>(P1 · C1-C9 + 通用组件)"]
|
||||
T03["T03: 性能优化与 UX 增强<br/>(P1 · Perf-1~9 + UX-1~7)"]
|
||||
T04["T04: 测试体系建设<br/>(P1 · T-1 + T-2 + T-4)"]
|
||||
T05["T05: 新功能<br/>(P1 · F-1 + F-2)"]
|
||||
|
||||
T01 --> T02
|
||||
T01 --> T03
|
||||
T01 --> T04
|
||||
T01 --> T05
|
||||
|
||||
style T01 fill:#FF6B6B,color:#fff
|
||||
style T02 fill:#FFD700,color:#333
|
||||
style T03 fill:#7BC67E,color:#fff
|
||||
style T04 fill:#5B9BD5,color:#fff
|
||||
style T05 fill:#8B5CF6,color:#fff
|
||||
```
|
||||
|
||||
**并行策略**:T01 完成后,T02/T03/T04/T05 可并行推进。其中 T03 与 T02 有轻微文件冲突(stores/),建议 T02 先行或约定合并策略。
|
||||
|
||||
---
|
||||
|
||||
## 七、依赖包列表
|
||||
|
||||
### 后端新增
|
||||
|
||||
```
|
||||
- node-cron@^3.0.3 # 定时任务调度(F-1 财务报告、F-2 预算扫描)
|
||||
- jest@^29.7.0 # 后端测试框架(T-1)
|
||||
- ts-jest@^29.1.1 # Jest TypeScript 支持
|
||||
- supertest@^6.3.3 # HTTP 集成测试(T-1)
|
||||
- @types/supertest@^6.0.2 # supertest 类型定义
|
||||
```
|
||||
|
||||
### 前端新增
|
||||
|
||||
```
|
||||
- vitest@^1.2.0 # 前端测试框架(T-2)
|
||||
- @pinia/testing@^0.1.3 # Pinia 测试工具
|
||||
- @vue/test-utils@^2.4.3 # Vue 组件测试工具
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、共享知识(跨文件约定)
|
||||
|
||||
### 8.1 API 响应格式
|
||||
|
||||
```typescript
|
||||
// 所有 API 统一响应格式
|
||||
{ code: 0, data: T, message?: string } // 成功
|
||||
{ code: 40100, message: '未登录' } // Token 无效
|
||||
{ code: 40101, message: 'token已过期' } // Token 过期(可刷新)
|
||||
{ code: 40300, message: '无权访问' } // 权限不足
|
||||
{ code: 40400, message: '资源不存在' } // 找不到
|
||||
{ code: 42900, message: '请求过于频繁' } // 限流
|
||||
{ code: 50000, message: '服务器错误' } // 服务端异常
|
||||
```
|
||||
|
||||
### 8.2 认证相关
|
||||
|
||||
```
|
||||
- Access Token 有效期:2 小时(HMAC-SHA256 签名,格式:base64(userId:timestamp:signature))
|
||||
- Refresh Token 有效期:30 天(随机 32 字节,存 SHA-256 hash)
|
||||
- 前端存储 Key:xc:accessToken, xc:refreshToken
|
||||
- Authorization Header:Bearer <accessToken>
|
||||
- 40101 表示 Token 过期可刷新;40100 表示 Token 无效需重新登录
|
||||
```
|
||||
|
||||
### 8.3 金额处理
|
||||
|
||||
```
|
||||
- 所有金额以「分」为单位存储(INT 类型)
|
||||
- 前端展示使用 formatAmount() 转为 "¥ 1,234.56" 格式
|
||||
- 输入时以「元」为单位,提交前乘以 100 转为分
|
||||
- 金额校验:1 <= amount <= 999999999(分)
|
||||
```
|
||||
|
||||
### 8.4 日期处理
|
||||
|
||||
```
|
||||
- 数据库存储 DATE 类型(YYYY-MM-DD)
|
||||
- 月份参数格式:YYYY-MM(如 "2026-06")
|
||||
- 时区统一:服务器 + 客户端均按 UTC+8 处理
|
||||
- getCurrentMonth() 前后端逻辑一致
|
||||
```
|
||||
|
||||
### 8.5 软删除约定
|
||||
|
||||
```
|
||||
- users 表使用 deleted_at 字段标记软删除
|
||||
- 所有查询 users 表的 SQL 必须增加 WHERE deleted_at IS NULL
|
||||
- 登录时检查 deleted_at IS NULL
|
||||
- 群组成员查询需过滤已删除用户
|
||||
- 已删除用户在群组统计中标注"已注销用户"
|
||||
```
|
||||
|
||||
### 8.6 乐观更新约定
|
||||
|
||||
```
|
||||
- 新增/编辑操作:先更新本地 state → 再调 API → 失败回滚 + toast
|
||||
- 删除操作:仍等 API 确认后再移除(防误删)
|
||||
- 排序操作:全量回滚(排序是完整 ID 数组操作)
|
||||
- 临时 ID:使用负数时间戳(-Date.now()),API 成功后替换为真实 ID
|
||||
```
|
||||
|
||||
### 8.7 前端缓存约定
|
||||
|
||||
```
|
||||
- 低频变更数据(分类/标签):5 分钟缓存,onShow 判断是否需要刷新
|
||||
- 未读计数:30 秒缓存
|
||||
- 缓存失效:手动操作(新增/编辑/删除)后立即清除对应缓存
|
||||
- 下拉刷新:强制清除缓存并重新请求
|
||||
```
|
||||
|
||||
### 8.8 组件命名约定
|
||||
|
||||
```
|
||||
- 组件目录:client/src/components/ComponentName/ComponentName.vue
|
||||
- Store 命名:use{Name}Store
|
||||
- API 模块:client/src/api/{name}.ts
|
||||
- 页面目录:client/src/pages/{name}/index.vue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、待明确事项
|
||||
|
||||
| # | 问题 | 影响范围 | 当前假设 | 风险 |
|
||||
|---|------|----------|----------|------|
|
||||
| Q1 | Refresh Token 存储方式 | S5 | 存 MySQL `refresh_tokens` 表;短中期可行,高并发时迁移 Redis | 当前写入频率极低,MySQL 足够 |
|
||||
| Q2 | 软删除级联范围 | S6 | 仅软删除用户账号,交易记录保留但通过 `deleted_at IS NULL` 过滤 | 保留交易可能影响群组统计,需标注"已注销用户" |
|
||||
| Q3 | 删除撤销批量上限 | UX-1 | 上限 5 条(Snackbar 最多显示"已删除 5 条记录"),超过直接删除 | 5 条覆盖绝大多数场景 |
|
||||
| Q4 | 离线记账冲突策略 | UX-6 | MVP 仅支持离线新增(不涉及修改/删除),新增使用服务端生成 ID,不存在主键冲突 | 如需离线编辑需引入版本号机制 |
|
||||
| Q5 | 微信订阅消息模板审核 | F-1 | 优先站内通知,微信订阅消息作为增强项;需提前申请模板审核 | 微信审核可能被拒,需准备备选方案 |
|
||||
| Q6 | 预算预警检查时机 | F-2 | 双重策略:记账后实时检查 + 每日凌晨全量扫描 | 仅实时检查可能遗漏周期记账触发的超支 |
|
||||
| Q7 | 乐观更新回滚粒度 | Perf-1 | 排序操作全量回滚(语义清晰),新增/编辑单条回滚 | 全量回滚可能丢失已做的其他排序操作 |
|
||||
| Q8 | 测试环境数据隔离 | T-1 | 独立测试数据库(.env.test),CI 用 GitHub Actions service container | 本地需额外配置测试数据库 |
|
||||
| Q9 | budgets 表 group_id 迁移 | UX-4 | 现有数据 group_id 默认 NULL(个人预算),UNIQUE KEY 从 (user_id, month) 改为 (user_id, group_id, month) | 迁移需确保不破坏现有预算数据 |
|
||||
| Q10 | iconfont 图标清单 | C8 | 需从现有 Icon.vue 的 PNG 映射中提取完整图标列表,并在 iconfont.cn 重新制作 | 部分图标可能无现成 SVG,需设计师配合 |
|
||||
|
||||
---
|
||||
|
||||
*文档版本: v2.0 | 最后更新: 2026-06-10*
|
||||
212
docs/class-diagram.mermaid
Normal file
212
docs/class-diagram.mermaid
Normal file
@@ -0,0 +1,212 @@
|
||||
classDiagram
|
||||
direction TB
|
||||
|
||||
class TokenConfig {
|
||||
+TOKEN_SECRET: string
|
||||
+ACCESS_TOKEN_EXPIRY: number
|
||||
+REFRESH_TOKEN_EXPIRY: number
|
||||
}
|
||||
|
||||
class AuthMiddleware {
|
||||
+authMiddleware(req, res, next): void
|
||||
-verifyHmacToken(token): userId
|
||||
-isTokenExpired(timestamp): boolean
|
||||
}
|
||||
|
||||
class AuthRoute {
|
||||
+POST /login: LoginResult
|
||||
+POST /demo-login: LoginResult
|
||||
+POST /refresh: RefreshResult
|
||||
-signToken(userId): string
|
||||
-generateRefreshToken(userId): string
|
||||
-hashToken(token): string
|
||||
-autoSetAdmin(userId): void
|
||||
}
|
||||
|
||||
class LoginResult {
|
||||
+accessToken: string
|
||||
+refreshToken: string
|
||||
+expiresIn: number
|
||||
+userId: number
|
||||
+nickname: string
|
||||
+avatar_url: string
|
||||
+role: string
|
||||
}
|
||||
|
||||
class RefreshResult {
|
||||
+accessToken: string
|
||||
+refreshToken: string
|
||||
+expiresIn: number
|
||||
}
|
||||
|
||||
class RefreshTokenRow {
|
||||
+id: number
|
||||
+user_id: number
|
||||
+token_hash: string
|
||||
+expires_at: Date
|
||||
+revoked_at: Date|null
|
||||
+created_at: Date
|
||||
}
|
||||
|
||||
class DownloadTokenRow {
|
||||
+id: number
|
||||
+token: string
|
||||
+user_id: number
|
||||
+resource_type: string
|
||||
+resource_id: string
|
||||
+expires_at: Date
|
||||
+used_at: Date|null
|
||||
+created_at: Date
|
||||
}
|
||||
|
||||
class DownloadTokenRoute {
|
||||
+POST /download-tokens: GenerateDownloadToken
|
||||
+GET /download/:token: FileStream
|
||||
-validateToken(token): DownloadTokenRow
|
||||
-markUsed(tokenId): void
|
||||
-cleanupExpired(): void
|
||||
}
|
||||
|
||||
class AdminRoute {
|
||||
+GET /dashboard: DashboardData
|
||||
+GET /users: UserList
|
||||
+PUT /users/:id/status: void
|
||||
+DELETE /users/:id: void~~软删除
|
||||
+POST /users/:id/restore: void~~恢复
|
||||
}
|
||||
|
||||
class UserRow {
|
||||
+id: number
|
||||
+openid: string
|
||||
+nickname: string
|
||||
+avatar_url: string
|
||||
+role: string
|
||||
+deleted_at: Date|null
|
||||
+created_at: Date
|
||||
}
|
||||
|
||||
class ManageListComponent {
|
||||
+items: T[]
|
||||
+displayField: string
|
||||
+colorField: string
|
||||
+showDrag: boolean
|
||||
+showEdit: boolean
|
||||
+showDelete: boolean
|
||||
+emptyText: string
|
||||
+emit_add(): void
|
||||
+emit_edit(item): void
|
||||
+emit_delete(item): void
|
||||
+emit_sort(ids): void
|
||||
}
|
||||
|
||||
class ColorPickerComponent {
|
||||
+modelValue: string
|
||||
+colors: string[]
|
||||
+emit_update:modelValue(color): void
|
||||
}
|
||||
|
||||
class EditModalComponent {
|
||||
+visible: boolean
|
||||
+title: string
|
||||
+fields: EditField[]
|
||||
+emit_confirm(data): void
|
||||
}
|
||||
|
||||
class SnackbarComponent {
|
||||
+message: string
|
||||
+actionText: string
|
||||
+duration: number
|
||||
+visible: boolean
|
||||
+emit_action(): void
|
||||
+show(msg, action): void
|
||||
+hide(): void
|
||||
}
|
||||
|
||||
class OfflineQueue {
|
||||
-pendingOps: PendingOp[]
|
||||
-isProcessing: boolean
|
||||
+enqueue(op): void
|
||||
+dequeue(id): void
|
||||
+processPendingOps(): Promise~void~
|
||||
+isOnline(): boolean
|
||||
+startNetworkListener(): void
|
||||
}
|
||||
|
||||
class PendingOp {
|
||||
+id: string
|
||||
+type: string
|
||||
+data: object
|
||||
+createdAt: number
|
||||
+status: string
|
||||
}
|
||||
|
||||
class SchedulerService {
|
||||
+start(): void
|
||||
+stop(): void
|
||||
-weeklyReportCron(): void
|
||||
-monthlyReportCron(): void
|
||||
-dailyBudgetScanCron(): void
|
||||
}
|
||||
|
||||
class ReportService {
|
||||
+generateWeeklyReport(userId): WeeklyReport
|
||||
+generateMonthlyReport(userId): MonthlyReport
|
||||
-queryPeriodData(userId, start, end): PeriodData
|
||||
}
|
||||
|
||||
class BudgetAlertService {
|
||||
+checkBudgetAlert(userId, month): void
|
||||
-calculateUsageRatio(userId, month): number
|
||||
-sendNotification(userId, level): void
|
||||
-sendWechatMessage(userId, level): void
|
||||
-hasAlerted(userId, month, level): boolean
|
||||
}
|
||||
|
||||
class WechatSubscribeService {
|
||||
+sendMessage(userId, templateId, data): void
|
||||
-getAccessToken(): string
|
||||
}
|
||||
|
||||
class BudgetAlertRow {
|
||||
+id: number
|
||||
+user_id: number
|
||||
+month: string
|
||||
+level: string
|
||||
+group_id: number|null
|
||||
+created_at: Date
|
||||
}
|
||||
|
||||
class UserSettings {
|
||||
+user_id: number
|
||||
+report_push_enabled: boolean
|
||||
+updated_at: Date
|
||||
}
|
||||
|
||||
class StatsRoute {
|
||||
+GET /overview: Overview
|
||||
+GET /category: CategoryStat[]
|
||||
+GET /trend: TrendPoint[]
|
||||
+GET /dashboard: DashboardData
|
||||
-buildWhereClause(userId, groupId): WhereResult
|
||||
}
|
||||
|
||||
class DashboardData {
|
||||
+overview: Overview
|
||||
+category: CategoryStat[]
|
||||
+trend: TrendPoint[]
|
||||
}
|
||||
|
||||
TokenConfig <-- AuthMiddleware : uses
|
||||
TokenConfig <-- AuthRoute : uses
|
||||
AuthRoute --> LoginResult : returns
|
||||
AuthRoute --> RefreshResult : returns
|
||||
AuthRoute --> RefreshTokenRow : creates/revokes
|
||||
DownloadTokenRoute --> DownloadTokenRow : creates/validates
|
||||
AdminRoute --> UserRow : soft deletes
|
||||
SchedulerService --> ReportService : triggers
|
||||
SchedulerService --> BudgetAlertService : triggers
|
||||
ReportService --> WechatSubscribeService : uses
|
||||
BudgetAlertService --> WechatSubscribeService : uses
|
||||
BudgetAlertService --> BudgetAlertRow : creates
|
||||
BudgetAlertService --> UserSettings : checks
|
||||
StatsRoute --> DashboardData : returns
|
||||
254
docs/prd-alignment.md
Normal file
254
docs/prd-alignment.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# 小菜记账 — 前后端对齐增量 PRD
|
||||
|
||||
> 版本: v1.0 | 日期: 2026-07-11
|
||||
> 类型: 增量 PRD(仅包含变更部分)
|
||||
> 基线: 前后端对齐分析发现的 8 项差距
|
||||
|
||||
---
|
||||
|
||||
## 一、产品目标
|
||||
|
||||
| # | 目标 | 说明 |
|
||||
|---|------|------|
|
||||
| G1 | 消灭前后端差距,实现已开发后端能力的前端落地 | 后端已有排序、备份、健康检查等端点,但前端缺失对应 UI |
|
||||
| G2 | 补齐用户反馈闭环,提升产品可信度 | 用户提交反馈后无法查看回复,体验断裂 |
|
||||
| G3 | 扩展数据维度与吞吐能力 | 支持年度/周度统计、服务端导出、数据导入,为数据量增长做准备 |
|
||||
|
||||
---
|
||||
|
||||
## 二、用户故事
|
||||
|
||||
### P0 — 分类拖拽排序
|
||||
|
||||
> As a 用户, I want 在分类管理页面拖拽调整分类顺序, so that 常用分类排在前面,记账时选得更快。
|
||||
|
||||
### P1 — 反馈回复展示
|
||||
|
||||
> As a 用户, I want 查看自己提交的反馈列表及管理员回复, so that 我知道反馈是否被处理以及处理结果。
|
||||
|
||||
### P1 — 备份管理页面
|
||||
|
||||
> As a 管理员, I want 在前端触发备份、查看备份列表并下载备份文件, so that 我可以在出现问题时快速恢复数据。
|
||||
|
||||
### P2 — 统计年度/周视图
|
||||
|
||||
> As a 用户, I want 在统计页面切换年/周/月维度, so that 我可以从不同时间粒度分析收支趋势。
|
||||
|
||||
### P2 — 数据导入与服务端导出
|
||||
|
||||
> As a 用户, I want 导入 JSON 格式的交易数据, so that 我可以从其他记账工具迁移历史数据。
|
||||
>
|
||||
> As a 用户, I want 大数据量导出走服务端生成, so that 导出速度快、不卡顿。
|
||||
|
||||
### P3 — 健康检查
|
||||
|
||||
> As a 管理员, I want 在管理页面看到后端服务运行状态, so that 我能第一时间发现服务异常。
|
||||
|
||||
### P3 — 交易标签系统
|
||||
|
||||
> As a 用户, I want 给交易添加标签(如"出差""聚餐"), so that 我可以按标签筛选和统计关联交易。
|
||||
>
|
||||
> As a 用户, I want 按标签筛选交易列表和查看标签维度的统计, so that 我可以追踪特定场景的收支。
|
||||
|
||||
---
|
||||
|
||||
## 三、需求池
|
||||
|
||||
### P0 — Must Have
|
||||
|
||||
| ID | 需求 | 前端 | 后端 | 验收标准 |
|
||||
|----|------|------|------|----------|
|
||||
| P0-1 | 分类拖拽排序 | 分类管理页面添加拖拽排序 UI,拖拽结束后调用 `sortCategories(ids)` | 无新增(`PUT /categories/sort` 已实现) | 拖拽后顺序持久化;刷新页面顺序保持;支出/收入分类各自排序 |
|
||||
|
||||
### P1 — Should Have
|
||||
|
||||
| ID | 需求 | 前端 | 后端 | 验收标准 |
|
||||
|----|------|------|------|----------|
|
||||
| P1-1 | 用户反馈列表 | 反馈页面新增"我的反馈"Tab,展示反馈列表及管理员回复 | 新增 `GET /feedback/mine` 端点(用户维度,返回当前用户的反馈+`admin_reply`) | 用户只能看自己的反馈;未回复显示"待处理"状态;已回复显示回复内容和时间 |
|
||||
| P1-2 | 备份管理页面 | 新增备份管理页面:触发备份按钮、备份列表、下载按钮 | 新增 `GET /backup/:id/download` 端点 | 管理员可触发备份并看到进度;列表显示备份时间和大小;点击下载获取备份文件 |
|
||||
|
||||
### P2 — Nice to Have
|
||||
|
||||
| ID | 需求 | 前端 | 后端 | 验收标准 |
|
||||
|----|------|------|------|----------|
|
||||
| P2-1 | 统计年度/周视图 | 统计页面添加维度切换器(周/月/年),切换后图表和排行联动更新 | `GET /stats/category` 和 `GET /stats/trend` 支持 `period=week|month|year` 参数,按对应维度聚合数据 | 周视图显示本周每日数据;年视图显示每月汇总;切换时图表平滑过渡 |
|
||||
| P2-2 | 数据导入 | 我的页面新增"数据导入"入口;支持 JSON 文件上传;导入前预览数据条数,确认后提交 | 新增 `POST /api/transactions/import` 端点,接收 JSON 文件,校验格式,批量写入 | 支持 JSON 格式;导入前展示条数预览;重复数据(同日期/金额/分类)跳过并提示;导入完成显示成功/跳过计数 |
|
||||
| P2-3 | 数据导出服务端化 | 数据导出页面新增"服务端导出"选项;大数据量(>1000条)自动走服务端 | 新增 `GET /api/export` 端点,支持 CSV/JSON 格式,流式响应 | 客户端导出保留作为轻量选项;服务端导出无条数限制;下载进度可感知 |
|
||||
|
||||
### P3 — Nice to Have(远期)
|
||||
|
||||
| ID | 需求 | 前端 | 后端 | 验收标准 |
|
||||
|----|------|------|------|----------|
|
||||
| P3-1 | 健康检查展示 | 管理页面新增服务状态卡片,显示后端健康信息 | 将 `/api/health` 端点移到 authMiddleware 之前 | 管理页面实时显示服务状态(正常/异常);无需登录即可调用健康检查 |
|
||||
| P3-2 | 交易标签 — 标签管理 | 新增标签管理页面:创建/编辑/删除标签(名称+颜色) | 新增 `tags` 表及 CRUD 端点(`GET/POST/PUT/DELETE /api/tags`) | 每个用户独立标签;标签不可重名;删除标签时解绑关联交易 |
|
||||
| P3-3 | 交易标签 — 交易关联 | 记账页面新增"添加标签"入口;交易详情/编辑页可修改标签 | `transactions` 表新增关联;`POST/PUT /transactions` 支持 `tagIds` 字段 | 一笔交易可关联多个标签;标签选择器展示用户已有标签 |
|
||||
| P3-4 | 交易标签 — 标签筛选统计 | 账单页支持按标签筛选;统计页新增标签维度视图 | `GET /transactions` 支持 `tagId` 筛选参数;`GET /stats/by-tag` 新端点 | 筛选结果仅显示含指定标签的交易;标签统计展示各标签下收支总额 |
|
||||
|
||||
---
|
||||
|
||||
## 四、UI 交互说明
|
||||
|
||||
### P0-1 分类拖拽排序
|
||||
|
||||
```
|
||||
分类管理页面(category-manage/index.vue)变更:
|
||||
|
||||
1. 页面顶部新增提示栏:"长按拖拽可调整分类顺序"
|
||||
2. 每个分类项左侧显示拖拽手柄图标(Lucide GripVertical, #BFB3B3)
|
||||
3. 长按分类项(300ms)进入拖拽模式:
|
||||
- 被拖拽项 elevation 提升(shadow-clay-float)
|
||||
- 背景色变为 #FFF0E6
|
||||
- 其余项轻微缩进让出空间
|
||||
4. 拖拽过程中实时交换位置(带 150ms 过渡动画)
|
||||
5. 松手后:
|
||||
- 调用 sortCategories(ids) 提交新顺序
|
||||
- 成功:轻量 toast "排序已保存"
|
||||
- 失败:回滚到原位置 + 错误提示
|
||||
6. 支出/收入 Tab 各自独立排序,互不影响
|
||||
```
|
||||
|
||||
### P1-1 用户反馈列表
|
||||
|
||||
```
|
||||
反馈页面(feedback/index.vue)变更:
|
||||
|
||||
1. 页面顶部新增 Tab 切换:"提交反馈" | "我的反馈"
|
||||
2. "我的反馈" Tab 内容:
|
||||
├── 列表按提交时间倒序排列
|
||||
├── 每项显示:
|
||||
│ ├── 反馈内容(截断2行,点击展开)
|
||||
│ ├── 提交时间(相对时间:"3天前")
|
||||
│ ├── 状态标签:
|
||||
│ │ ├── 待处理 → 灰色 #BFB3B3
|
||||
│ │ ├── 已回复 → 绿色 #7BC67E
|
||||
│ │ └── 已关闭 → 橙色 #FF8C69
|
||||
│ └── 管理员回复区域(仅已回复时显示):
|
||||
│ ├── 分割线 + "管理员回复" 标签
|
||||
│ ├── 回复内容
|
||||
│ └── 回复时间
|
||||
└── 空状态:插画 + "还没有提交过反馈"
|
||||
3. 下拉刷新更新列表
|
||||
4. 上拉加载更多(分页)
|
||||
```
|
||||
|
||||
### P1-2 备份管理页面
|
||||
|
||||
```
|
||||
新增页面(backup-manage/index.vue):
|
||||
|
||||
1. 顶部操作区:
|
||||
├── [立即备份] 主按钮(Clay 风格)
|
||||
├── 点击后按钮变为 loading 态
|
||||
└── 成功后列表顶部新增一条记录 + toast "备份成功"
|
||||
2. 备份列表:
|
||||
├── 按时间倒序
|
||||
├── 每项显示:
|
||||
│ ├── 备份时间(2026-07-11 14:30)
|
||||
│ ├── 文件大小(12.3 MB)
|
||||
│ └── [下载] 按钮(Lucide Download 图标)
|
||||
├── 点击下载:
|
||||
│ ├── 按钮变为进度条
|
||||
│ └── 完成后 toast "下载完成"
|
||||
└── 空状态:"暂无备份记录"
|
||||
3. 仅管理员角色可见此页面入口
|
||||
```
|
||||
|
||||
### P2-1 统计年度/周视图
|
||||
|
||||
```
|
||||
统计页面(stats/index.vue)变更:
|
||||
|
||||
1. 月份选择器升级为维度切换器:
|
||||
├── 新增三个 Tab:周 | 月 | 年
|
||||
├── 样式同现有支出/收入切换器
|
||||
└── 切换后日期选择器联动:
|
||||
├── 周:显示 "2026年7月 第2周",左右切换周
|
||||
├── 月:保持现有行为
|
||||
└── 年:显示 "2026年",左右切换年
|
||||
2. 图表联动更新:
|
||||
├── 周维度:趋势图 X 轴为周一~周日
|
||||
├── 年维度:趋势图 X 轴为1月~12月
|
||||
└── 环形图和排行同步更新为对应维度数据
|
||||
3. 概览卡片数值联动
|
||||
```
|
||||
|
||||
### P2-2 & P2-3 数据导入与导出
|
||||
|
||||
```
|
||||
我的页面变更 + 新增页面:
|
||||
|
||||
1. "数据导出"入口变更:
|
||||
├── 点击后进入导出页面
|
||||
├── 导出方式选择:
|
||||
│ ├── 客户端导出(< 1000 条推荐)— 现有逻辑
|
||||
│ └── 服务端导出(≥ 1000 条推荐)— 新增
|
||||
├── 格式选择:CSV | JSON
|
||||
├── 日期范围选择器
|
||||
└── [开始导出] 按钮
|
||||
2. 新增"数据导入"入口(我的页面功能列表):
|
||||
├── 点击后进入导入页面
|
||||
├── 步骤流程:
|
||||
│ ├── Step 1:选择文件(仅 .json)
|
||||
│ ├── Step 2:预览 — 显示识别条数、日期范围、分类映射
|
||||
│ ├── Step 3:确认导入
|
||||
│ └── 结果:成功 N 条 / 跳过 M 条
|
||||
└── 导入模板下载链接
|
||||
```
|
||||
|
||||
### P3-1 健康检查展示
|
||||
|
||||
```
|
||||
管理页面变更:
|
||||
|
||||
1. 页面顶部新增服务状态卡片:
|
||||
├── 正常:绿色圆点 + "服务运行正常" + 响应时间 "23ms"
|
||||
├── 异常:红色圆点 + "服务异常" + 错误信息
|
||||
└── 检测中:灰色圆点 + 旋转图标
|
||||
2. 每 30 秒自动刷新一次
|
||||
3. 点击卡片可手动刷新
|
||||
```
|
||||
|
||||
### P3-2~4 交易标签系统
|
||||
|
||||
```
|
||||
全局变更:
|
||||
|
||||
1. 记账页面(add/index.vue):
|
||||
├── 附加信息区新增"添加标签"行
|
||||
├── 点击弹出标签选择器(底部弹窗):
|
||||
│ ├── 已有标签列表(可多选)
|
||||
│ ├── 搜索框
|
||||
│ └── [新建标签] 快捷入口
|
||||
└── 已选标签以药丸标签形式展示在输入区
|
||||
2. 账单页面(bills/index.vue):
|
||||
├── 筛选栏新增标签筛选
|
||||
└── 筛选弹窗中增加标签选择
|
||||
3. 统计页面(stats/index.vue):
|
||||
└── 新增"标签统计"卡片(环形图 + 排行)
|
||||
4. 我的页面:
|
||||
└── 功能列表新增"标签管理"入口
|
||||
5. 标签管理页面(tag-manage/index.vue)— 新增:
|
||||
├── 标签列表(名称 + 颜色色块 + 关联交易数)
|
||||
├── [新建标签] 按钮
|
||||
├── 编辑弹窗:名称输入 + 颜色选择器(8色预设)
|
||||
└── 删除确认:关联 N 笔交易时提示"将解除关联"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、待确认问题
|
||||
|
||||
| # | 问题 | 影响范围 | 建议 |
|
||||
|---|------|----------|------|
|
||||
| Q1 | 分类拖拽排序是否需要动画反馈?拖拽手柄位置(左侧还是右侧)? | P0-1 | 建议左侧手柄 + 长按触发,符合主流交互习惯 |
|
||||
| Q2 | 用户反馈列表是否需要通知推送?当管理员回复后用户是否收到微信服务通知? | P1-1 | 后端已有通知逻辑,但微信订阅消息需用户主动订阅,建议先做页面内展示,通知作为后续增强 |
|
||||
| Q3 | 备份文件的下载鉴权方式?URL 是否需要时效性签名? | P1-2 | 建议使用临时签名 URL(有效期 1 小时),避免备份文件被猜测 URL 直接访问 |
|
||||
| Q4 | 统计年度视图的趋势图粒度:按月汇总还是按季汇总? | P2-1 | 建议按月汇总(12 个点),季度可通过后续分组实现 |
|
||||
| Q5 | 数据导入的 JSON 格式规范?是否需要支持其他记账 App 的导出格式? | P2-2 | 建议先定义小菜记账自有 JSON 格式,提供模板下载;其他 App 格式兼容作为远期需求 |
|
||||
| Q6 | 服务端导出的文件存储方式:内存生成直接流式返回 vs 先写文件再下载? | P2-3 | 建议 < 1 万条内存流式返回,≥ 1 万条先写临时文件再下载,避免内存溢出 |
|
||||
| Q7 | 交易标签上限:每笔交易最多关联几个标签?每个用户最多创建多少个标签? | P3-2~4 | 建议每笔交易最多 5 个标签,每用户最多 20 个标签,后续可调整 |
|
||||
| Q8 | 标签与分类的关系:标签是否可以跨分类?同一标签能否同时用于支出和收入? | P3-2~4 | 建议标签不区分收支类型,跨分类使用,这是标签区别于分类的核心价值 |
|
||||
|
||||
---
|
||||
|
||||
*文档版本: v1.0 | 最后更新: 2026-07-11*
|
||||
873
docs/prd-iteration-v2.md
Normal file
873
docs/prd-iteration-v2.md
Normal file
@@ -0,0 +1,873 @@
|
||||
# 小菜记账 — 全量迭代 PRD v2
|
||||
|
||||
> 版本: v2.0 | 日期: 2026-06-10
|
||||
> 类型: 全量迭代 PRD(安全性 · 代码质量 · 性能 · UX · 可测试性 · 新功能)
|
||||
> 基线: 已完成三轮迭代(代码审查修复 · UI 优化 · 前后端功能对齐 8 项差距)
|
||||
> 范围: 本次审查发现 35 项改进点,覆盖 6 个维度
|
||||
|
||||
---
|
||||
|
||||
## 一、产品目标
|
||||
|
||||
| # | 目标 | 说明 | 关联维度 |
|
||||
|---|------|------|----------|
|
||||
| G1 | 消除生产环境安全风险 | .env 泄露、SQL 注入、弱密钥、Token 暴露等 P0/P1 安全问题必须在上线前全部清零 | 安全性 |
|
||||
| G2 | 建立可持续的代码质量基线 | 消除重复代码、补齐事务与异常处理、统一规范,使后续迭代不会因技术债减速 | 代码质量 |
|
||||
| G3 | 核心路径性能感知提升 50% | 首页加载、分类/标签操作、统计查询等高频路径的响应时间减半 | 性能优化 |
|
||||
| G4 | 交互体验达到主流记账 App 水准 | 删除撤销、群组只读、离线记账、下拉刷新等缺失体验补齐 | 用户体验 |
|
||||
| G5 | 建立测试体系与核心功能扩展 | 从零测试覆盖到关键路径有保障,并新增财务报告与预算预警等高价值功能 | 可测试性 · 新功能 |
|
||||
|
||||
---
|
||||
|
||||
## 二、用户故事
|
||||
|
||||
### P0 — 安全紧急
|
||||
|
||||
> **S1** As a 产品负责人, I want .env 文件中的生产凭据从 Git 历史中彻底清除, so that 攻击者无法通过代码仓库获取数据库密码和微信密钥。
|
||||
|
||||
> **S2** As a 用户, I want 后端所有 SQL 查询使用参数化方式, so that 我的财务数据不会被 SQL 注入攻击窃取。
|
||||
|
||||
> **S3** As a 用户, I want 认证 Token 使用强密钥且命名统一, so that 伪造 Token 攻击无法成功。
|
||||
|
||||
### P1 — 安全高优
|
||||
|
||||
> **S4** As a 用户, I want 导出/备份下载链接使用一次性短期凭证, so that 我的财务数据不会被 URL 泄露导致未授权访问。
|
||||
|
||||
> **S5** As a 用户, I want 登录 Token 支持轮换机制, so that 即使 Token 泄露也能在短时间内失效。
|
||||
|
||||
> **S6** As a 管理员, I want 删除用户时使用软删除并需二次确认, so that 不会因误操作导致用户数据永久丢失。
|
||||
|
||||
> **S7** As a 运维人员, I want 数据库连接不硬编码默认凭据, so that 未配置环境变量时服务直接报错而非以弱凭据连接。
|
||||
|
||||
### P1 — 代码质量
|
||||
|
||||
> **C1** As a 开发者, I want 消除前后端重复的工具函数, so that 修改逻辑时不需要同步多处代码。
|
||||
|
||||
> **C2** As a 开发者, I want tag-manage 与 category-manage 共用可复用组件, so that 两个管理页面行为一致且维护成本低。
|
||||
|
||||
> **C4** As a 用户, I want 周期记账同步操作在数据库事务中执行, so that 部分失败不会产生脏数据。
|
||||
|
||||
> **C5** As a 开发者, I want Store 的异步操作都有 try/catch, so that 未捕获异常不会导致界面卡死。
|
||||
|
||||
> **C7** As a 开发者, I want 所有 API 地址从 config.ts 统一读取, so that 环境切换不会遗漏。
|
||||
|
||||
### P1 — 性能优化
|
||||
|
||||
> **Perf-1** As a 用户, I want 添加/编辑分类后列表立即更新而无需等待接口返回, so that 操作反馈无延迟感。
|
||||
|
||||
> **Perf-2** As a 用户, I want 统计页面的多个接口并行请求, so that 页面加载不会串行等待。
|
||||
|
||||
### P1 — 用户体验
|
||||
|
||||
> **UX-1** As a 用户, I want 删除记录后 3 秒内可以撤销, so that 误删不会导致数据永久丢失。
|
||||
|
||||
> **UX-2** As a 群组成员, I want 看到非本人记录时显示只读标记, so that 我不会误编辑他人记录。
|
||||
|
||||
> **UX-3** As a 用户, I want 预算设置支持群组维度, so that 群组场景下也能设置和跟踪预算。
|
||||
|
||||
### P1 — 可测试性
|
||||
|
||||
> **T-1** As a 开发者, I want 后端路由有集成测试覆盖, so that 接口变更不会静默破坏功能。
|
||||
|
||||
### P1 — 新功能
|
||||
|
||||
> **F-1** As a 用户, I want 每周/月收到财务报告推送, so that 我不用主动打开 App 也能了解收支状况。
|
||||
|
||||
> **F-2** As a 用户, I want 预算达到 80%/100%/120% 时收到预警通知, so that 我能及时控制支出。
|
||||
|
||||
---
|
||||
|
||||
## 三、需求池
|
||||
|
||||
### P0 — Must Have(安全紧急,阻塞上线)
|
||||
|
||||
| ID | 维度 | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|------|----------|----------|
|
||||
| S1 | 安全 | .env 凭据泄露修复 | 全局 | `.env` 在 `.gitignore` 中(已有但需验证);Git 历史中不含任何凭据明文;所有已泄露密钥已轮换 |
|
||||
| S2 | 安全 | logs.ts SQL 拼接修复 | `server/routes/logs.ts` | `LIMIT ${pSize} OFFSET ${offset}` 改为 `pool.query` + 参数占位符 `?`;或改为 `parseInt` + 范围校验后内插 |
|
||||
| S3 | 安全 | TOKEN_SECRET 弱默认值 + 命名不一致 | `server/middleware/auth.ts`, `server/routes/auth.ts`, `server/routes/backup.ts` | 统一为 `TOKEN_SECRET`;移除所有 fallback 默认值,未配置时进程拒绝启动 |
|
||||
|
||||
### P1 — Should Have
|
||||
|
||||
#### 安全
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| S4 | Export/Backup URL 传认证 token → 短期一次性下载凭证 | `server/routes/export.ts`, `server/routes/backup.ts` | 下载凭证有效期 5 分钟,一次性使用后失效;不使用 query string 传长期 Token |
|
||||
| S5 | Refresh Token 机制 | `server/routes/auth.ts`, `server/middleware/auth.ts`, `client/src/utils/request.ts` | Access Token 有效期缩短至 2h;Refresh Token 有效期 30d;Refresh Token 支持轮换(用后旧 Token 失效) |
|
||||
| S6 | Admin 硬删除 → 软删除 + 二次确认 | `server/routes/admin.ts`, DB schema | `users` 表新增 `deleted_at` 字段;删除操作改为 `SET deleted_at = NOW()`;需前端二次确认弹窗;查询自动过滤 `deleted_at IS NULL` |
|
||||
| S7 | DB 连接移除默认凭据 | `server/src/db/connection.ts` | 移除 `|| 'localhost'`、`|| 'xiaocai'`、`|| 'xiaocai123'` 等 fallback;未配置时 `createPool` 抛出明确错误 |
|
||||
|
||||
#### 代码质量
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| C1 | 重复工具函数抽取 | 前后端 `utils/` | 识别并合并功能相同的工具函数(如日期格式化、金额处理);抽取为共享模块或分别去重 |
|
||||
| C2 | tag-manage / category-manage 重复代码 | 前端页面 | 抽取 `ManagePageLayout` 可复用组件(列表 + 新增 + 编辑 + 删除 + 拖拽排序);两页面基于该组件定制 |
|
||||
| C4 | Recurring 同步缺事务 | `server/routes/recurring.ts` | `/sync` 接口使用 `pool.getConnection()` + `connection.beginTransaction()` 包裹;部分失败时 `rollback()` |
|
||||
| C5 | Store 缺 try/catch | `client/src/stores/*.ts` | 所有 Store 中 `await api.xxx()` 调用包裹 try/catch;catch 中使用统一错误提示(toast);不吞错误 |
|
||||
| C7 | 硬编码 URL | 前端全局 | 全局搜索 `http://` 和 `xiaocai.j35.site`,替换为 `config.ts` 导出的变量 |
|
||||
|
||||
#### 性能优化
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| Perf-1 | 分类/标签乐观更新 | `client/stores/category.ts`, `client/stores/tag.ts` | 新增/编辑/排序操作先更新本地 state 再调接口;失败时回滚并提示;删除仍等接口确认后移除 |
|
||||
| Perf-2 | groupStore 并行加载 | `client/stores/group.ts` | `refreshAll()` 中多个 `fetchXxx()` 改为 `Promise.all()` 并行执行 |
|
||||
| Perf-3 | Stats 聚合接口 | `server/routes/stats.ts` | 合并 `/stats/overview` + `/stats/category` + `/stats/trend` 为单一 `/stats/dashboard` 接口;减少 3 次请求为 1 次 |
|
||||
| Perf-4 | Track 批量 INSERT | `server/routes/track.ts` | 多条埋点数据合并为单条 `INSERT INTO ... VALUES (?,?), (?)` 批量写入 |
|
||||
| Perf-5 | Admin N+1 查询 | `server/routes/admin.ts` | 用户列表查询改为 JOIN 单次获取关联数据,消除循环内查询 |
|
||||
| Perf-6 | 前端数据缓存 | `client/stores/*.ts` | 分类、标签等低频变更数据增加内存缓存标记,`onShow` 时判断是否需刷新 |
|
||||
| Perf-7 | 未读计数缓存 | `client/stores/notification.ts` | 未读计数结果缓存 30s,避免每次 `onShow` 都请求 |
|
||||
|
||||
#### 用户体验
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| UX-1 | 删除撤销 | `client/pages/bills/`, `client/stores/transaction.ts` | 删除后底部弹出 Snackbar,3 秒内可点击"撤销"恢复;撤销成功数据不变;超时后真正删除 |
|
||||
| UX-2 | 群组只读标记 | `client/pages/bills/`, `client/components/TransactionItem/` | 非本人记录显示小锁图标;点击进入查看模式而非编辑模式;查看模式下字段不可修改 |
|
||||
| UX-3 | waitForReady 超时处理 | `client/utils/app-ready.ts` | 超时后 reject 并在页面级捕获,展示"网络异常,请检查网络后重试"提示;不再静默继续 |
|
||||
| UX-4 | 预算 group_id | `server/routes/budget.ts`, `client/stores/budget.ts`, `client/pages/budget/` | 预算设置/查询支持 `group_id` 参数;群组视图下预算卡片显示群组总预算 + 我的份额 |
|
||||
|
||||
#### 可测试性
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| T-1 | 后端路由集成测试 | `server/` | 使用 Jest + supertest;覆盖 auth、transaction、category、budget 核心 CRUD 路由;断言状态码 + 返回格式 + 权限校验 |
|
||||
|
||||
#### 新功能
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| F-1 | 月度/周度财务报告推送 | 后端定时任务 + 微信订阅消息 | 每周一早 9 点推送上周收支摘要;每月 1 号推送上月收支报告;使用微信订阅消息模板;用户可在设置中开关 |
|
||||
| F-2 | 预算超支预警 | 后端定时任务 + 通知系统 | 80% 时站内通知提醒;100% 时站内通知 + 微信订阅消息;120% 时站内紧急通知 + 微信订阅消息;预算检查在记账后实时触发 |
|
||||
|
||||
### P2 — Nice to Have
|
||||
|
||||
#### 代码质量
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| C3 | 上传重复逻辑抽取 | `server/routes/user.ts`, `server/routes/notification.ts` | 抽取 `handleUpload(dir, file)` 公共函数,统一 MIME 校验 + 大小限制 + 文件名生成 |
|
||||
| C6 | Store 吞错误修复 | `client/stores/*.ts` | catch 块中至少 `console.error` + 用户可见 toast 提示;不再空 catch |
|
||||
| C8 | Icon 组件 → iconfont | `client/components/Icon/` | 将 PNG 图标映射替换为 iconfont 字体图标;减小包体积;支持动态颜色 |
|
||||
| C9 | 头像 URL 拼接重复 | 前端多处 | 抽取 `getAvatarUrl(filename)` 工具函数,统一拼接 `API_BASE + 路径 + 文件名` |
|
||||
|
||||
#### 性能优化
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| Perf-8 | Logs 流式读取 | `server/routes/logs.ts` | 大量日志数据使用 stream 分批返回,避免一次性加载到内存 |
|
||||
| Perf-9 | ChartWrapper 延迟渲染 | `client/components/ChartWrapper/` | 图表组件进入可视区域后才开始渲染(IntersectionObserver);非可视区域显示占位 |
|
||||
|
||||
#### 用户体验
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| UX-5 | 下拉刷新统一 | 前端所有数据页面 | 所有列表页面启用 `onPullDownRefresh`;统一在 `pages.json` 配置 `enablePullDownRefresh` |
|
||||
| UX-6 | 离线支持 | `client/` 全局 | 记账操作存入本地队列 + 网络状态监听;网络恢复后自动同步;离线期间操作标记为"待同步"状态 |
|
||||
| UX-7 | Loading 态统一 | 前端全局 | 所有异步操作添加 loading 态(骨架屏 / spinner / 按钮禁用);操作期间禁止重复提交 |
|
||||
|
||||
#### 可测试性
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| T-2 | Composables 测试 | `client/` | 抽取可复用逻辑为 composables;使用 Vitest 编写单元测试 |
|
||||
| T-3 | 依赖注入 | `client/` | API 调用通过 provide/inject 注入,便于测试时 mock |
|
||||
|
||||
#### 新功能
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| F-3 | 多设备同步 | 后端 + 前端 | 基于 `updated_at` 时间戳的增量同步;冲突策略:后写入优先;同步状态可视化 |
|
||||
| F-4 | AA 分账 | 后端 + 前端 | 群组内选择多笔交易,按人均/自定义比例分摊;生成每人应付/应收金额;支持结算确认 |
|
||||
| F-5 | 智能记账 | 后端 + 前端 | 备注关键词自动匹配分类(如"滴滴"→交通);历史金额学习建议;最近使用分类优先 |
|
||||
|
||||
### P3 — 远期规划
|
||||
|
||||
#### 代码质量
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| C10 | 路由中间件一致性 | `server/routes/*.ts` | 统一所有路由的中间件注册方式(统一在 `index.ts` 注册还是路由内自注册) |
|
||||
|
||||
#### 性能优化
|
||||
|
||||
| ID | 需求 | 影响范围 | 验收标准 |
|
||||
|----|------|----------|----------|
|
||||
| Perf-10 | 数据可视化增强 | `client/pages/stats/` | 支持更丰富的图表类型(饼图、折线图、柱状图);支持图表数据导出为图片 |
|
||||
|
||||
---
|
||||
|
||||
## 四、安全修复方案
|
||||
|
||||
### S1:.env 凭据泄露修复
|
||||
|
||||
**当前状态**:`.env` 已在 `.gitignore` 中,但文件内容已在历史提交中暴露,包含数据库地址 `115.120.243.74`、密码 `dnjwYbpMdmASCxfH`、微信 AppID/Secret。
|
||||
|
||||
**修复步骤**:
|
||||
|
||||
1. **立即轮换所有已泄露凭据**:
|
||||
- MySQL: 修改 `xiaocai_test` 用户密码
|
||||
- 微信: 在微信公众平台重置 AppSecret
|
||||
- TOKEN_SECRET: 生成新的强随机密钥(`openssl rand -hex 32`)
|
||||
2. **清除 Git 历史**:
|
||||
```bash
|
||||
# 使用 git-filter-repo(推荐,比 BFG 更安全)
|
||||
pip install git-filter-repo
|
||||
git filter-repo --path server/.env --invert-paths
|
||||
git filter-repo --blob-callback 'blob.data = blob.data.replace(b"dnjwYbpMdmASCxfH", b"REDACTED")'
|
||||
```
|
||||
3. **强制推送并通知协作者**:
|
||||
```bash
|
||||
git push origin --force --all
|
||||
```
|
||||
4. **验证 `.gitignore` 生效**:
|
||||
```bash
|
||||
git check-ignore server/.env # 应输出 server/.env
|
||||
```
|
||||
5. **添加 pre-commit hook 防止再次提交**:
|
||||
```bash
|
||||
# .githooks/pre-commit
|
||||
if git diff --cached --name-only | grep -q '\.env'; then
|
||||
echo "ERROR: .env files must not be committed"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
**回滚方案**:Git 历史重写前创建完整备份 `git clone --mirror`。
|
||||
|
||||
### S2:logs.ts SQL 拼接修复
|
||||
|
||||
**当前状态**:`server/routes/logs.ts:160-168` 使用模板字符串拼接 LIMIT/OFFSET:
|
||||
|
||||
```typescript
|
||||
// ❌ 当前代码
|
||||
LIMIT ${pSize} OFFSET ${offset}
|
||||
```
|
||||
|
||||
**修复方案**:
|
||||
|
||||
```typescript
|
||||
// ✅ 方案 A:参数化查询(推荐)
|
||||
const safeLimit = Math.min(Math.max(parseInt(String(pSize)) || 10, 1), 100)
|
||||
const safeOffset = Math.max(parseInt(String(offset)) || 0, 0)
|
||||
const [rows] = await pool.query(
|
||||
`SELECT t.*, u.nickname
|
||||
FROM track_events t
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
WHERE ${where}
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, safeLimit, safeOffset]
|
||||
)
|
||||
```
|
||||
|
||||
**关键点**:`LIMIT`/`OFFSET` 的值必须先 `parseInt` + 范围校验,防止非整数或超大值;`pool.query` 支持 `?` 占位符用于整数参数(与 `pool.execute` 的 prepared statement 不同)。
|
||||
|
||||
### S3:TOKEN_SECRET 弱默认值 + 命名不一致
|
||||
|
||||
**当前状态**:
|
||||
- `auth.ts` / `middleware/auth.ts`:`process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'`
|
||||
- `backup.ts`:`process.env.JWT_SECRET || 'xiaocai-secret'`(命名不一致 + 弱默认值)
|
||||
- `.env` 中:`TOKEN_SECRET=xiaocai-prod-secret-2026-change-me`(弱密钥)
|
||||
|
||||
**修复方案**:
|
||||
|
||||
1. **统一命名为 `TOKEN_SECRET`**:
|
||||
- `backup.ts` 中 `JWT_SECRET` → `TOKEN_SECRET`
|
||||
- 删除 `const JWT_SECRET = ...` 声明,改为从 `middleware/auth.ts` 导出或统一读取 `process.env.TOKEN_SECRET`
|
||||
|
||||
2. **移除所有 fallback 默认值,强制要求环境变量**:
|
||||
```typescript
|
||||
// server/src/config/token.ts(新增)
|
||||
export const TOKEN_SECRET = process.env.TOKEN_SECRET
|
||||
if (!TOKEN_SECRET || TOKEN_SECRET.length < 32) {
|
||||
console.error('[Security] TOKEN_SECRET must be set and at least 32 characters')
|
||||
process.exit(1)
|
||||
}
|
||||
```
|
||||
|
||||
3. **生成强密钥**:
|
||||
```bash
|
||||
# 在服务器上生成并写入 .env
|
||||
echo "TOKEN_SECRET=$(openssl rand -hex 32)" >> server/.env
|
||||
```
|
||||
|
||||
4. **所有引用处改为**:
|
||||
```typescript
|
||||
import { TOKEN_SECRET } from '../config/token'
|
||||
```
|
||||
|
||||
### S4:Export/Backup URL 短期一次性下载凭证
|
||||
|
||||
**当前状态**:
|
||||
- `backup.ts`:下载链接使用 HMAC 签名的 `backupId:timestamp:hmac` 格式 token,通过 query string 传递
|
||||
- `export.ts`:导出接口支持 query string 传 token 兼容
|
||||
|
||||
**问题**:虽然已有 HMAC 签名,但无过期时间校验(backup 有 timestamp 但未校验时效),且 token 可重复使用。
|
||||
|
||||
**修复方案**:
|
||||
|
||||
1. **新增 `download_tokens` 表**:
|
||||
```sql
|
||||
CREATE TABLE download_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
user_id INT NOT NULL,
|
||||
resource_type ENUM('backup', 'export') NOT NULL,
|
||||
resource_id VARCHAR(100),
|
||||
expires_at DATETIME NOT NULL,
|
||||
used_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_token (token),
|
||||
INDEX idx_expires (expires_at)
|
||||
);
|
||||
```
|
||||
|
||||
2. **生成下载凭证**:
|
||||
```typescript
|
||||
// 有效期 5 分钟
|
||||
const token = randomBytes(32).toString('hex')
|
||||
await pool.query(
|
||||
'INSERT INTO download_tokens (token, user_id, resource_type, resource_id, expires_at) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE))',
|
||||
[token, userId, 'backup', backupId]
|
||||
)
|
||||
```
|
||||
|
||||
3. **验证下载凭证**:
|
||||
```typescript
|
||||
const [rows] = await pool.query(
|
||||
'SELECT * FROM download_tokens WHERE token = ? AND user_id = ? AND expires_at > NOW() AND used_at IS NULL',
|
||||
[token, userId]
|
||||
)
|
||||
if (!rows.length) return res.status(403).json({ code: 40300, message: '下载凭证无效或已过期' })
|
||||
// 标记为已使用
|
||||
await pool.query('UPDATE download_tokens SET used_at = NOW() WHERE id = ?', [rows[0].id])
|
||||
```
|
||||
|
||||
4. **定期清理过期 token**:在 health check 或定时任务中清理 `expires_at < NOW()` 的记录。
|
||||
|
||||
### S5:Refresh Token 机制
|
||||
|
||||
**当前状态**:单 Token 机制,HMAC-SHA256 签名,30 天过期,无轮换。
|
||||
|
||||
**修复方案**:
|
||||
|
||||
1. **双 Token 机制**:
|
||||
- Access Token:有效期 2 小时,用于 API 调用
|
||||
- Refresh Token:有效期 30 天,仅用于刷新 Access Token
|
||||
|
||||
2. **新增 `refresh_tokens` 表**:
|
||||
```sql
|
||||
CREATE TABLE refresh_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
revoked_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_hash (token_hash)
|
||||
);
|
||||
```
|
||||
|
||||
3. **认证流程**:
|
||||
- 登录:返回 `accessToken` + `refreshToken`
|
||||
- API 调用:Header 带 `Authorization: Bearer <accessToken>`
|
||||
- Token 过期(401):前端用 `refreshToken` 调用 `/auth/refresh` 获取新 `accessToken` + 新 `refreshToken`(旧 refresh token 失效)
|
||||
- Refresh Token 也过期:需重新登录
|
||||
|
||||
4. **前端适配**(`client/src/utils/request.ts`):
|
||||
- 现有 401 重登录机制改为 401 → refresh → 重试
|
||||
- refresh 也失败(401)→ 清除本地 token → 跳转登录
|
||||
|
||||
5. **安全措施**:
|
||||
- Refresh Token 存储 hash 值(SHA-256),不存明文
|
||||
- 每个 Refresh Token 仅使用一次(用后旧 token 失效)
|
||||
- 用户修改密码时撤销所有 Refresh Token
|
||||
|
||||
### S6:Admin 硬删除 → 软删除 + 二次确认
|
||||
|
||||
**当前状态**:`admin.ts:170` 直接 `DELETE FROM users WHERE id = ?`,无二次确认。
|
||||
|
||||
**修复方案**:
|
||||
|
||||
1. **DB Schema 变更**:
|
||||
```sql
|
||||
ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL;
|
||||
CREATE INDEX idx_users_deleted ON users(deleted_at);
|
||||
```
|
||||
|
||||
2. **后端修改**:
|
||||
```typescript
|
||||
// 软删除
|
||||
router.delete('/users/:id', async (req, res) => {
|
||||
await pool.query('UPDATE users SET deleted_at = NOW() WHERE id = ?', [req.params.id])
|
||||
res.json({ code: 0, data: { message: '用户已禁用' } })
|
||||
})
|
||||
|
||||
// 恢复
|
||||
router.post('/users/:id/restore', async (req, res) => {
|
||||
await pool.query('UPDATE users SET deleted_at = NULL WHERE id = ?', [req.params.id])
|
||||
res.json({ code: 0, data: { message: '用户已恢复' } })
|
||||
})
|
||||
```
|
||||
|
||||
3. **全局查询过滤**:在 `auth.ts` 登录时检查 `deleted_at IS NULL`;其他查询同理。
|
||||
|
||||
4. **前端二次确认**:
|
||||
```
|
||||
管理员点击"删除用户" → 弹出确认弹窗:
|
||||
"确定要禁用用户「{nickname}」吗?该操作将冻结该用户的所有数据,但不会删除记录。"
|
||||
[取消] [确认禁用]
|
||||
```
|
||||
|
||||
### S7:DB 连接移除默认凭据
|
||||
|
||||
**当前状态**:`connection.ts` 中 `password: process.env.DB_PASSWORD || 'xiaocai123'` 等硬编码默认值。
|
||||
|
||||
**修复方案**:
|
||||
|
||||
```typescript
|
||||
// ✅ 修复后
|
||||
const required = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'] as const
|
||||
for (const key of required) {
|
||||
if (!process.env[key]) {
|
||||
console.error(`[DB] Missing required environment variable: ${key}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST!,
|
||||
user: process.env.DB_USER!,
|
||||
password: process.env.DB_PASSWORD!,
|
||||
database: process.env.DB_NAME!,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、UI 交互说明
|
||||
|
||||
### UX-1:删除撤销
|
||||
|
||||
```
|
||||
交互流程:
|
||||
|
||||
1. 用户在账单列表左滑删除某条记录
|
||||
2. 记录从列表中移除(动画:向左滑出)
|
||||
3. 底部弹出 Snackbar:
|
||||
┌───────────────────────────────────────────┐
|
||||
│ 🗑 已删除 1 条记录 [撤销] │
|
||||
└───────────────────────────────────────────┘
|
||||
- 背景色: $surface (#FFFFFF)
|
||||
- 文字色: $text-sec (#8B7E7E)
|
||||
- "撤销"按钮: $primary (#FF8C69) + 500 字重
|
||||
- 自动消失时间: 3 秒
|
||||
- 从底部上滑动画: 200ms ease-out
|
||||
4. 3 秒内点击"撤销":
|
||||
- 记录重新插入列表原位置(动画:从左侧滑入)
|
||||
- 不调用后端删除接口
|
||||
- Snackbar 消失
|
||||
5. 3 秒超时未操作:
|
||||
- Snackbar 下滑消失
|
||||
- 调用后端 DELETE /transactions/:id
|
||||
- 如果接口失败,记录恢复到列表 + 错误提示
|
||||
|
||||
实现要点:
|
||||
- 删除操作先只移除前端列表项,不立即调接口
|
||||
- 使用 pendingDelete Map 存储待删除记录(id → 原始数据 + 原位置索引)
|
||||
- 多次快速删除时 Snackbar 累加计数:"已删除 N 条记录"
|
||||
- 撤销时按 LIFO 顺序恢复
|
||||
```
|
||||
|
||||
### UX-2:群组只读标记
|
||||
|
||||
```
|
||||
交互设计:
|
||||
|
||||
1. TransactionItem 组件变更:
|
||||
- 新增 props: `isReadOnly: boolean`
|
||||
- 非本人记录(transaction.user_id !== currentUserId):
|
||||
├── 右上角显示小锁图标(Lucide Lock, 16rpx, $text-sec 颜色)
|
||||
├── 左滑不显示删除按钮
|
||||
└── 整体透明度降为 0.85(区分视觉层级)
|
||||
|
||||
2. 点击交互:
|
||||
- 本人记录:点击 → 编辑页面(现有行为)
|
||||
- 非本人记录:点击 → 查看页面(只读模式)
|
||||
├── 页面布局与编辑页相同
|
||||
├── 所有输入框/选择器为 disabled 态
|
||||
├── 金额/分类/标签仅展示不可修改
|
||||
├── 底部无"保存"按钮
|
||||
└── 顶部标题显示"查看记录"
|
||||
|
||||
3. 群组视图下的记账按钮:
|
||||
- 仍然可以新增自己的记录
|
||||
- 新增记录自动归属当前用户
|
||||
```
|
||||
|
||||
### UX-3:waitForReady 超时处理
|
||||
|
||||
```
|
||||
交互流程:
|
||||
|
||||
1. 页面 onMounted 调用 await waitForReady()
|
||||
2. 如果 8 秒内未就绪:
|
||||
- waitForReady() reject(而非当前 resolve)
|
||||
- 页面 catch 中显示全屏错误状态:
|
||||
┌─────────────────────────┐
|
||||
│ │
|
||||
│ ⚠️ 网络连接异常 │
|
||||
│ 请检查网络后点击重试 │
|
||||
│ │
|
||||
│ [重新加载] │
|
||||
│ │
|
||||
└─────────────────────────┘
|
||||
- "重新加载"按钮点击后重新执行 waitForReady() + loadData()
|
||||
- 不再静默以未登录状态加载数据
|
||||
```
|
||||
|
||||
### UX-4:预算 group_id 支持
|
||||
|
||||
```
|
||||
交互设计:
|
||||
|
||||
1. 预算页面(budget/index.vue)变更:
|
||||
- 个人视图:显示"我的月度预算"(现有行为不变)
|
||||
- 群组视图:显示两个卡片:
|
||||
├── 群组总预算卡片:所有成员预算之和 + 群组总支出
|
||||
│ └── "群组总预算 ¥ 8,000.00"
|
||||
└── 我的预算卡片:当前用户个人预算 + 个人支出
|
||||
└── "我的预算 ¥ 3,000.00"
|
||||
|
||||
2. 设置预算弹窗变更:
|
||||
- 个人视图:设置个人月度预算
|
||||
- 群组视图:设置"我在群组中的月度预算"
|
||||
- 弹窗标题根据视图切换
|
||||
|
||||
3. 预算进度条变更:
|
||||
- 个人视图:支出/预算(现有)
|
||||
- 群组视图:
|
||||
├── 总进度条:群组总支出 / 群组总预算
|
||||
└── 我的进度条:我的支出 / 我的预算(子进度条样式)
|
||||
```
|
||||
|
||||
### UX-5:下拉刷新统一
|
||||
|
||||
```
|
||||
实现规范:
|
||||
|
||||
1. pages.json 全局配置:
|
||||
"globalStyle": {
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
|
||||
2. 所有数据页面统一模式:
|
||||
onPullDownRefresh(async () => {
|
||||
await loadData()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
3. 需要启用下拉刷新的页面:
|
||||
- 首页 index
|
||||
- 账单页 bills
|
||||
- 统计页 stats
|
||||
- 通知中心 notifications
|
||||
- 群组管理 group-manage
|
||||
- 标签管理 tag-manage
|
||||
|
||||
4. 不启用下拉刷新的页面:
|
||||
- 表单页(add, profile-edit, budget)
|
||||
- 设置页
|
||||
```
|
||||
|
||||
### UX-6:离线支持
|
||||
|
||||
```
|
||||
架构设计:
|
||||
|
||||
1. 操作队列(localStorage):
|
||||
- Key: `xc:pendingOps`
|
||||
- 数据结构:
|
||||
[
|
||||
{ id: 'uuid', type: 'create', data: {...}, createdAt: timestamp, status: 'pending' },
|
||||
{ id: 'uuid', type: 'update', data: {...}, createdAt: timestamp, status: 'pending' }
|
||||
]
|
||||
|
||||
2. 网络状态监听:
|
||||
onMounted(() => {
|
||||
uni.onNetworkStatusChange(({ isConnected }) => {
|
||||
if (isConnected) processPendingOps()
|
||||
})
|
||||
})
|
||||
|
||||
3. 操作执行流程:
|
||||
- 有网络:正常调接口
|
||||
- 无网络:
|
||||
├── 写入操作队列
|
||||
├── 本地 state 立即更新(乐观更新)
|
||||
├── 记录标记为"待同步"状态(右下角小图标)
|
||||
└── Toast:"已保存,将在网络恢复后同步"
|
||||
|
||||
4. 同步流程(processPendingOps):
|
||||
- 按时间顺序逐条执行
|
||||
- 全部成功:清除队列 + toast "同步完成"
|
||||
- 部分失败:标记失败项 + toast "N 条同步失败" + 保留在队列中重试
|
||||
|
||||
5. 限制范围:
|
||||
- MVP 阶段仅支持"新增记账"离线操作
|
||||
- 编辑/删除等操作离线时禁用(提示"请连接网络后操作")
|
||||
```
|
||||
|
||||
### UX-7:Loading 态统一
|
||||
|
||||
```
|
||||
规范:
|
||||
|
||||
1. 页面级加载:使用 Skeleton 骨架屏组件
|
||||
- 首次加载 onMounted 时显示
|
||||
- 下拉刷新不显示骨架屏(数据已存在)
|
||||
|
||||
2. 操作级加载:按钮/操作区域 loading 态
|
||||
- 按钮点击后 disabled + 显示 spinner
|
||||
- 防止重复提交(debounce 300ms 或 loading ref 守卫)
|
||||
|
||||
3. 全局 loading:
|
||||
- 页面切换时的导航栏 loading(uni.showNavigationBarLoading)
|
||||
- 长时间操作(导出/备份)使用全屏 loading + 进度提示
|
||||
|
||||
4. 空状态:
|
||||
- 数据为空时显示空状态插画 + 引导文案
|
||||
- 与加载中状态明确区分(不是空白页)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、性能优化方案
|
||||
|
||||
### Perf-1:分类/标签乐观更新
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 当前问题 | 新增/编辑/排序分类后,等待接口返回才更新列表,用户感知延迟 200-500ms |
|
||||
| 优化策略 | 先更新本地 Pinia state,再异步调接口;接口失败时回滚 state + toast 提示 |
|
||||
| 实现方式 | Store 方法内部:先 `state.items.push(newItem)` → `await api.create()` → 失败则 `state.items.pop()` + `toast('操作失败')` |
|
||||
| 预期效果 | 操作感知延迟从 200-500ms 降至 < 16ms(一帧) |
|
||||
| 适用范围 | 新增分类/标签、编辑分类/标签、拖拽排序;删除操作仍等接口确认(防误删) |
|
||||
|
||||
### Perf-2:groupStore 并行加载
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 当前问题 | `refreshAll()` 中 `fetchCategories()` → `fetchTags()` → `fetchTransactions()` 串行执行,总耗时 = 3 次请求之和 |
|
||||
| 优化策略 | 无依赖的请求改为 `Promise.all()` 并行执行 |
|
||||
| 实现方式 | `await Promise.all([fetchCategories(), fetchTags()]); await fetchTransactions()`(transactions 依赖 categories 的映射) |
|
||||
| 预期效果 | 并行部分耗时 = max(单次请求),总体减少 40-60% |
|
||||
|
||||
### Perf-3:Stats 聚合接口
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 当前问题 | 统计页加载时串行调用 `/stats/overview` + `/stats/category` + `/stats/trend`,3 次 HTTP 往返 |
|
||||
| 优化策略 | 合并为单一 `/stats/dashboard` 接口,后端并行查询后一次性返回 |
|
||||
| 实现方式 | 后端新路由中 `Promise.all([queryOverview, queryCategory, queryTrend])` → 返回 `{ overview, category, trend }` |
|
||||
| 预期效果 | 3 次 HTTP 往返 → 1 次;减少约 200ms 网络开销 |
|
||||
|
||||
### Perf-4:Track 批量 INSERT
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 当前问题 | 前端一次页面访问可能触发多条埋点,每条单独 INSERT,N 条 = N 次数据库写入 |
|
||||
| 优化策略 | 前端批量发送,后端使用 `INSERT INTO ... VALUES (?), (?), (?)` 批量写入 |
|
||||
| 实现方式 | 前端 `tracker.ts` 攒批(500ms 或 10 条),批量 POST;后端 `track.ts` 接收数组后构建批量 INSERT |
|
||||
| 预期效果 | 10 条埋点从 10 次 INSERT 降至 1 次,减少数据库连接开销 90% |
|
||||
|
||||
### Perf-5:Admin N+1 查询
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 当前问题 | 用户列表查询后,循环每个用户查询其交易数/群组数等信息,N 个用户 = 1 + N 次查询 |
|
||||
| 优化策略 | 使用 LEFT JOIN + GROUP BY 在一次查询中获取所有关联数据 |
|
||||
| 实现方式 | `SELECT u.*, COUNT(DISTINCT t.id) as tx_count, COUNT(DISTINCT gm.group_id) as group_count FROM users u LEFT JOIN transactions t ON ... LEFT JOIN group_members gm ON ... GROUP BY u.id` |
|
||||
| 预期效果 | 1+N 次查询 → 1 次查询;列表加载从 O(n) 降至 O(1) |
|
||||
|
||||
### Perf-6:前端数据缓存
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 当前问题 | 分类、标签等低频变更数据每次 `onShow` 都重新请求 |
|
||||
| 优化策略 | 增加 `lastFetchTime` 标记,5 分钟内 `onShow` 跳过请求 |
|
||||
| 实现方式 | Store 中增加 `lastFetchTime: Ref<number>`,`fetch()` 前判断 `Date.now() - lastFetchTime.value < 5 * 60 * 1000` 则跳过 |
|
||||
| 预期效果 | 页面切换时减少 60-80% 的冗余请求 |
|
||||
|
||||
### Perf-7:未读计数缓存
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 当前问题 | 每次进入"我的"页面或 `onShow` 都请求未读通知计数 |
|
||||
| 优化策略 | 计数结果缓存 30 秒;读取通知后主动清零缓存 |
|
||||
| 实现方式 | `unreadCount` 请求后记录 `lastFetchTime`,30s 内直接返回缓存值;`markAllRead()` 后 `unreadCount = 0` + 清缓存 |
|
||||
| 预期效果 | 频繁切换页面时减少通知计数请求 80%+ |
|
||||
|
||||
### Perf-8:Logs 流式读取(P2)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 当前问题 | 管理后台查看日志时全量加载到内存 |
|
||||
| 优化策略 | 使用 Node.js stream 分批返回 |
|
||||
| 实现方式 | `fs.createReadStream(logPath).pipe(res)` 或数据库 `queryStream()` |
|
||||
| 预期效果 | 内存占用从 O(n) 降至 O(buffer_size) |
|
||||
|
||||
### Perf-9:ChartWrapper 延迟渲染(P2)
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 当前问题 | 统计页所有图表同时渲染,低端设备卡顿 |
|
||||
| 优化策略 | 使用 IntersectionObserver,图表进入可视区域后才渲染 |
|
||||
| 实现方式 | ChartWrapper 组件内监听可视状态,不可见时显示占位(固定高度 div) |
|
||||
| 预期效果 | 首屏渲染时间减少 30-50% |
|
||||
|
||||
---
|
||||
|
||||
## 七、测试体系建设计划
|
||||
|
||||
> 目标:从零覆盖到核心路径有保障,分 4 阶段逐步建设
|
||||
|
||||
### 阶段一:后端路由集成测试(P1,预计 2 周)
|
||||
|
||||
**范围**:核心 CRUD 路由的集成测试
|
||||
|
||||
**技术栈**:Jest + supertest + 测试数据库
|
||||
|
||||
**覆盖清单**:
|
||||
|
||||
| 模块 | 测试用例 | 优先级 |
|
||||
|------|----------|--------|
|
||||
| auth | 登录成功/失败、Token 生成与验证、无效 Token 拒绝 | P0 |
|
||||
| transaction | CRUD、group_id 过滤、分页、金额精度 | P0 |
|
||||
| category | CRUD、默认/自定义分类隔离、排序、迁移 | P1 |
|
||||
| budget | CRUD、月度预算、群组视图 | P1 |
|
||||
| stats | overview/category/trend 查询、period 参数校验 | P2 |
|
||||
|
||||
**实现步骤**:
|
||||
|
||||
1. 安装依赖:`npm i -D jest ts-jest supertest @types/supertest`
|
||||
2. 创建测试数据库配置:`.env.test` 指向独立测试库
|
||||
3. 编写 `tests/setup.ts`:每个测试套件前清空测试库 + seed
|
||||
4. 编写路由测试:使用 `supertest(app)` 发送请求,断言状态码 + 返回格式
|
||||
5. 配置 `npm test` 命令
|
||||
|
||||
**验收标准**:核心路由测试覆盖率 ≥ 80%,`npm test` 全绿。
|
||||
|
||||
### 阶段二:前端 Store 单元测试(P1,预计 1.5 周)
|
||||
|
||||
**范围**:Pinia Store 的核心逻辑测试
|
||||
|
||||
**技术栈**:Vitest + @pinia/testing
|
||||
|
||||
**覆盖清单**:
|
||||
|
||||
| Store | 测试用例 | 优先级 |
|
||||
|-------|----------|--------|
|
||||
| transaction | fetchList/fetchDetail/create/update/delete、乐观更新、删除撤销 | P0 |
|
||||
| category | fetchAll/create/update/sort/乐观更新 | P1 |
|
||||
| budget | fetch/set/群组视图 | P1 |
|
||||
| group | fetchGroups/switchToPersonal/leaveGroup(BUG-03 回归) | P0 |
|
||||
| user | login/logout/token 管理 | P1 |
|
||||
|
||||
**实现步骤**:
|
||||
|
||||
1. 安装依赖:`npm i -D vitest @pinia/testing @vue/test-utils`
|
||||
2. 创建 `vitest.config.ts`
|
||||
3. Mock API 调用:使用 `vi.mock('@/api/*')`
|
||||
4. 编写 Store 测试:使用 `createTestingPinia()`
|
||||
5. 配置 `npm run test:unit` 命令
|
||||
|
||||
**验收标准**:Store 核心方法测试覆盖率 ≥ 70%。
|
||||
|
||||
### 阶段三:API 契约测试(P2,预计 1 周)
|
||||
|
||||
**范围**:确保前后端 API 接口格式一致
|
||||
|
||||
**技术栈**:Jest + JSON Schema 验证
|
||||
|
||||
**实现方式**:
|
||||
|
||||
1. 定义 API 响应 Schema(`tests/schemas/`):
|
||||
```typescript
|
||||
// transaction-response.schema.ts
|
||||
export const transactionListSchema = {
|
||||
type: 'object',
|
||||
required: ['code', 'data'],
|
||||
properties: {
|
||||
code: { const: 0 },
|
||||
data: {
|
||||
type: 'object',
|
||||
required: ['list', 'total'],
|
||||
properties: {
|
||||
list: { type: 'array' },
|
||||
total: { type: 'number' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. 集成测试中同时验证响应格式符合 Schema
|
||||
3. 前端 `request.ts` 添加开发模式响应校验
|
||||
|
||||
**验收标准**:所有已定义 Schema 的接口响应 100% 符合契约。
|
||||
|
||||
### 阶段四:工具函数单元测试(P2,预计 1 周)
|
||||
|
||||
**范围**:前端 `utils/` 和后端 `utils/` 的纯函数测试
|
||||
|
||||
**覆盖清单**:
|
||||
|
||||
| 模块 | 测试用例 |
|
||||
|------|----------|
|
||||
| `format.ts` | `formatAmount`、`formatDate`(含跨年 BUG-02 回归)、`formatAmountRaw` |
|
||||
| `request.ts` | 401 重试队列、超时处理、请求参数格式 |
|
||||
| `app-ready.ts` | waitForReady 正常/超时场景 |
|
||||
| `backup.ts`(后端) | 备份文件生成、签名校验 |
|
||||
| `date.ts`(后端) | `getCurrentMonth`、`getMonthRange` |
|
||||
|
||||
**验收标准**:工具函数测试覆盖率 ≥ 90%。
|
||||
|
||||
---
|
||||
|
||||
## 八、待确认问题
|
||||
|
||||
| # | 问题 | 影响范围 | 建议方案 | 风险 |
|
||||
|---|------|----------|----------|------|
|
||||
| Q1 | Refresh Token 存储方式:数据库 vs Redis | S5 | 当前技术栈无 Redis,建议存 MySQL `refresh_tokens` 表;如果后续并发量增长,迁移到 Redis | MySQL 写入频率低,短中期可行 |
|
||||
| Q2 | 软删除的级联范围:是否需要同时软删除该用户的所有交易记录? | S6 | 建议仅软删除用户账号(`deleted_at`),交易记录保留但通过 `deleted_at IS NULL` 过滤;历史数据完整性更重要 | 保留交易可能影响群组统计,需在群组视图中标注"已注销用户" |
|
||||
| Q3 | 删除撤销的批量操作上限:最多支持同时撤销几条? | UX-1 | 建议上限 5 条(Snackbar 最多显示"已删除 5 条记录");超过 5 条时直接删除不走撤销流程 | 5 条覆盖绝大多数场景,过多会增加本地状态管理复杂度 |
|
||||
| Q4 | 离线记账的冲突策略:如果离线期间同一条记录被其他设备修改了怎么办? | UX-6 | MVP 阶段仅支持离线新增(不涉及修改/删除),新增记录使用服务端生成的 ID,不存在主键冲突 | 如需支持离线编辑,需引入版本号或时间戳对比机制 |
|
||||
| Q5 | 财务报告推送的微信订阅消息模板审核:微信对金融类模板审核严格,是否需要用户额外授权? | F-1 | 建议先实现站内通知(现有通知系统),微信订阅消息作为增强项;需提前申请微信模板消息审核 | 微信审核可能被拒,需准备备选方案(如短信或仅站内推送) |
|
||||
| Q6 | 预算预警的检查时机:仅在记账后检查 vs 定时全量扫描? | F-2 | 建议双重策略:记账后实时检查(精准、低开销)+ 每日凌晨全量扫描(覆盖非记账场景如周期记账自动生成) | 仅实时检查可能遗漏周期记账触发的超支 |
|
||||
| Q7 | 乐观更新的回滚粒度:分类排序失败时,是回滚全部排序还是仅回滚失败项? | Perf-1 | 建议回滚全部:排序是全量操作(传递完整 ID 数组),部分回滚语义不清晰;失败后恢复排序前快照 + toast | 全量回滚用户感知更一致,但可能丢失用户已做的其他排序操作 |
|
||||
| Q8 | 测试环境数据隔离:集成测试使用独立数据库还是 Docker 容器? | T-1 | 建议独立测试数据库(`.env.test`),不用 Docker(减少 CI 复杂度);CI 环境中用 GitHub Actions service container 启动 MySQL | 本地开发需额外配置测试数据库,但比 Docker 方案简单 |
|
||||
|
||||
---
|
||||
|
||||
## 九、迭代排期建议
|
||||
|
||||
| 阶段 | 时间 | 内容 | 交付物 |
|
||||
|------|------|------|--------|
|
||||
| **Phase 0** | 第 1 周 | P0 安全修复(S1/S2/S3) | 凭据轮换完成、SQL 参数化、TOKEN_SECRET 强制 |
|
||||
| **Phase 1** | 第 2-3 周 | P1 安全(S4-S7)+ P1 代码质量(C1/C2/C4/C5/C7) | 双 Token 机制、软删除、代码去重 |
|
||||
| **Phase 2** | 第 4-5 周 | P1 性能(Perf-1~7)+ P1 UX(UX-1~4) | 乐观更新、聚合接口、删除撤销、群组只读 |
|
||||
| **Phase 3** | 第 6-7 周 | P1 测试(T-1)+ P1 新功能(F-1/F-2) | 集成测试覆盖、财务报告推送、预算预警 |
|
||||
| **Phase 4** | 第 8-10 周 | P2 需求(C3/C6/C8/C9 + Perf-8~9 + UX-5~7 + T-2~3 + F-3~5) | 离线支持、AA 分账、智能记账 |
|
||||
| **Phase 5** | 远期 | P3 需求(C10 + Perf-10) | 路由中间件统一、数据可视化增强 |
|
||||
|
||||
---
|
||||
|
||||
*文档版本: v2.0 | 最后更新: 2026-06-10*
|
||||
92
docs/sequence-diagram.mermaid
Normal file
92
docs/sequence-diagram.mermaid
Normal file
@@ -0,0 +1,92 @@
|
||||
sequenceDiagram
|
||||
participant C as Client (request.ts)
|
||||
participant S as Server (auth.ts)
|
||||
participant M as Server (auth middleware)
|
||||
participant DB as MySQL (refresh_tokens)
|
||||
|
||||
Note over C,S: 1. 正常登录
|
||||
C->>S: POST /auth/login { code }
|
||||
S->>DB: INSERT refresh_tokens (user_id, token_hash, expires_at)
|
||||
S-->>C: { accessToken(2h), refreshToken(30d), userId }
|
||||
|
||||
Note over C,S: 2. API 调用(Access Token 有效)
|
||||
C->>M: GET /api/xxx Authorization: Bearer accessToken
|
||||
M->>M: 验证 HMAC 签名 + 有效期
|
||||
M-->>C: 200 OK { code: 0, data }
|
||||
|
||||
Note over C,S: 3. Access Token 过期
|
||||
C->>M: GET /api/xxx Authorization: Bearer expiredToken
|
||||
M->>M: 签名有效但已过期
|
||||
M-->>C: 401 { code: 40101, message: token已过期 }
|
||||
|
||||
Note over C,S: 4. 自动刷新
|
||||
C->>S: POST /auth/refresh { refreshToken }
|
||||
S->>DB: SELECT WHERE token_hash=SHA256(refreshToken) AND revoked_at IS NULL AND expires_at>NOW()
|
||||
DB-->>S: 找到记录
|
||||
S->>DB: UPDATE refresh_tokens SET revoked_at=NOW() WHERE id=oldId
|
||||
S->>DB: INSERT refresh_tokens (新 token_hash)
|
||||
S-->>C: { accessToken, refreshToken, expiresIn }
|
||||
C->>C: 存储新 Token,重试原请求
|
||||
|
||||
Note over C,S: 5. Refresh Token 也过期
|
||||
C->>S: POST /auth/refresh { expiredRefreshToken }
|
||||
S->>DB: SELECT WHERE token_hash=SHA256(...) AND expires_at>NOW()
|
||||
DB-->>S: 未找到记录
|
||||
S-->>C: 401 { code: 40101, message: refresh token已过期 }
|
||||
C->>C: 清除所有 Token,跳转登录页
|
||||
|
||||
Note over C,S: 6. 删除撤销流程
|
||||
participant U as User
|
||||
participant P as BillsPage
|
||||
participant ST as TransactionStore
|
||||
participant SN as Snackbar
|
||||
participant API as Server API
|
||||
|
||||
U->>P: 左滑删除记录
|
||||
P->>ST: pendingDelete(id, item, index)
|
||||
ST->>ST: 从 transactions 列表移除
|
||||
ST->>SN: 显示 Snackbar 已删除1条记录 撤销
|
||||
|
||||
alt 3秒内点击撤销
|
||||
U->>SN: 点击 撤销
|
||||
SN->>ST: cancelDelete(id)
|
||||
ST->>ST: 恢复到列表原位置
|
||||
else 3秒超时
|
||||
SN->>ST: confirmDelete(id)
|
||||
ST->>API: DELETE /api/transactions/id
|
||||
alt 删除成功
|
||||
API-->>ST: 200 OK
|
||||
ST->>ST: 清除 pendingDelete 记录
|
||||
else 删除失败
|
||||
API-->>ST: 500 Error
|
||||
ST->>ST: 恢复到列表原位置
|
||||
ST->>U: toast 删除失败
|
||||
end
|
||||
end
|
||||
|
||||
Note over C,S: 7. 预算预警流程
|
||||
participant T as Transaction Route
|
||||
participant BA as BudgetAlert Service
|
||||
participant NS as Notification System
|
||||
participant WX as WeChat API
|
||||
|
||||
C->>T: POST /api/transactions { amount, type, date }
|
||||
T->>DB: INSERT INTO transactions
|
||||
T->>BA: checkBudgetAlert(userId, month)
|
||||
BA->>DB: SELECT budget FROM budgets WHERE user_id=? AND month=?
|
||||
BA->>DB: SELECT SUM(amount) FROM transactions WHERE user_id=? AND type=expense
|
||||
|
||||
alt 支出/预算 >= 80% 且未推送过
|
||||
BA->>DB: INSERT budget_alerts (level=80)
|
||||
BA->>NS: 创建站内通知
|
||||
else 支出/预算 >= 100%
|
||||
BA->>DB: INSERT budget_alerts (level=100)
|
||||
BA->>NS: 创建站内通知
|
||||
BA->>WX: 发送微信订阅消息
|
||||
else 支出/预算 >= 120%
|
||||
BA->>DB: INSERT budget_alerts (level=120)
|
||||
BA->>NS: 创建紧急站内通知 (is_urgent=true)
|
||||
BA->>WX: 发送微信订阅消息
|
||||
end
|
||||
|
||||
T-->>C: { code: 0, data: { id } }
|
||||
320
docs/superpowers/plans/2026-06-08-s1-comprehensive-scan.md
Normal file
320
docs/superpowers/plans/2026-06-08-s1-comprehensive-scan.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# S1 全面扫描 — 实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 对「小菜记账」全项目进行三维度(缺陷/UI/功能)全面扫描,产出结构化问题清单
|
||||
|
||||
**Architecture:** 5 个并行扫描组 + 1 个汇总任务。每组用 Explore agent 深度检查文件,从缺陷/UI/功能三个维度输出 P0-P3 优先级发现。
|
||||
|
||||
**Tech Stack:** TypeScript, Vue 3, Uni-app, Express, MySQL
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
client/src/ server/src/
|
||||
├── pages/ ├── index.ts
|
||||
│ ├── index/index.vue ├── middleware/
|
||||
│ ├── add/index.vue │ ├── auth.ts
|
||||
│ ├── bills/ │ ├── logger.ts
|
||||
│ │ ├── index.vue │ └── requireAdmin.ts
|
||||
│ │ └── filter-panel.vue ├── routes/
|
||||
│ ├── stats/index.vue │ ├── auth.ts
|
||||
│ ├── profile/index.vue │ ├── transaction.ts
|
||||
│ ├── profile-edit/index.vue │ ├── category.ts
|
||||
│ ├── budget/index.vue │ ├── budget.ts
|
||||
│ ├── category-manage/index.vue │ ├── stats.ts
|
||||
│ ├── group-manage/index.vue │ ├── user.ts
|
||||
│ ├── notifications/index.vue │ ├── group.ts
|
||||
│ ├── feedback/index.vue │ ├── notification.ts
|
||||
│ ├── privacy/index.vue │ ├── filter.ts
|
||||
│ └── admin/ │ ├── feedback.ts
|
||||
│ ├── index.vue │ ├── config.ts
|
||||
│ ├── users.vue │ ├── admin.ts
|
||||
│ ├── notifications.vue │ ├── track.ts
|
||||
│ ├── feedback.vue │ ├── logs.ts
|
||||
│ ├── config.vue │ └── backup.ts
|
||||
│ ├── logs.vue ├── utils/
|
||||
│ └── track.vue │ ├── backup.ts
|
||||
├── components/ │ └── date.ts
|
||||
│ ├── Icon/Icon.vue └── db/
|
||||
│ ├── Numpad/Numpad.vue ├── connection.ts
|
||||
│ ├── TransactionItem/...vue └── init.ts
|
||||
│ ├── Skeleton/Skeleton.vue
|
||||
│ ├── SaveSuccess/...vue
|
||||
│ ├── BudgetBar/BudgetBar.vue
|
||||
│ ├── CategoryIcon/...vue
|
||||
│ └── ChartWrapper/...vue
|
||||
├── stores/
|
||||
│ ├── transaction.ts
|
||||
│ ├── category.ts
|
||||
│ ├── budget.ts
|
||||
│ ├── stats.ts
|
||||
│ ├── user.ts
|
||||
│ ├── group.ts
|
||||
│ ├── filter.ts
|
||||
│ ├── notification.ts
|
||||
│ └── config.ts
|
||||
├── api/
|
||||
│ ├── auth.ts, transaction.ts, category.ts
|
||||
│ ├── budget.ts, stats.ts, user.ts
|
||||
│ ├── group.ts, notification.ts, filter.ts
|
||||
│ ├── feedback.ts, config.ts, admin.ts, logs.ts
|
||||
│ └── index.ts
|
||||
├── utils/
|
||||
│ ├── request.ts, format.ts, system.ts
|
||||
│ ├── app-ready.ts, tracker.ts
|
||||
└── config.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 扫描检查清单(每个文件按此标准检查)
|
||||
|
||||
每个文件从以下三个维度检查:
|
||||
|
||||
### 🔧 缺陷维度
|
||||
- [ ] API 参数类型:`request()` 调用是否使用 `{ url, method?, data? }` 格式
|
||||
- [ ] null/undefined 处理:是否可能传递 undefined 到查询参数
|
||||
- [ ] 边界条件:空数组、空字符串、0 值的处理
|
||||
- [ ] 竞态条件:快速切换页面时请求是否可能覆盖
|
||||
- [ ] 小程序适配:胶囊按钮避让 (capsuleRight)、scroll-view 高度
|
||||
- [ ] 数据加载:onMounted 是否先 await waitForReady()
|
||||
- [ ] catch 块:是否有用户提示而非静默失败
|
||||
- [ ] 后端响应格式:是否返回 `{ code: 0, data }` 或 `{ code: xxx, message }`
|
||||
|
||||
### 🎨 UI 维度
|
||||
- [ ] 加载态:是否有 loading/skeleton,避免白屏
|
||||
- [ ] 空状态:无数据时是否有友好的空状态提示
|
||||
- [ ] 错误态:是否有错误提示机制
|
||||
- [ ] 触摸反馈:可点击元素是否有 `:active` 样式
|
||||
- [ ] 设计一致性:颜色/圆角/阴影使用 SCSS 变量,不硬编码
|
||||
- [ ] 触摸目标:交互元素 ≥ 44×44px
|
||||
- [ ] emoji:禁止使用 emoji,必须用 Icon 组件
|
||||
- [ ] 动画:是否尊重 prefers-reduced-motion
|
||||
- [ ] 文字对比度:≥ 4.5:1 (WCAG AA)
|
||||
|
||||
### 🚀 功能维度
|
||||
- [ ] 分页:是否正确分页,是否有「没有更多了」
|
||||
- [ ] 筛选:筛选条件变更时是否重置页码
|
||||
- [ ] 输入校验:前端是否有校验,后端是否有校验
|
||||
- [ ] 错误码规范:0/40001/40300/40400/50000
|
||||
- [ ] 数据流:API → store → 组件是否清晰
|
||||
|
||||
### 优先级定义
|
||||
| 级别 | 含义 | 示例 |
|
||||
|------|------|------|
|
||||
| P0 | 数据/安全/崩溃 | 金额错误、SQL 注入、白屏 |
|
||||
| P1 | 功能不可用 | 按钮无响应、数据不刷新、接口报错 |
|
||||
| P2 | 体验不佳 | 空状态缺失、加载闪烁 |
|
||||
| P3 | 建议改进 | 代码整洁、命名、注释 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 扫描组 1 — 核心页面
|
||||
|
||||
**Files (13):**
|
||||
- `client/src/pages/index/index.vue`
|
||||
- `client/src/pages/add/index.vue`
|
||||
- `client/src/pages/bills/index.vue`
|
||||
- `client/src/pages/bills/filter-panel.vue`
|
||||
- `client/src/pages/stats/index.vue`
|
||||
- `client/src/stores/transaction.ts`
|
||||
- `client/src/stores/category.ts`
|
||||
- `client/src/stores/budget.ts`
|
||||
- `client/src/stores/stats.ts`
|
||||
- `client/src/api/transaction.ts`
|
||||
- `client/src/api/category.ts`
|
||||
- `client/src/api/budget.ts`
|
||||
- `client/src/api/stats.ts`
|
||||
|
||||
- [ ] **Step 1: 并行读取所有文件**
|
||||
|
||||
使用 Explore agent,读取上述全部 13 个文件,对每个文件按「扫描检查清单」逐项检查。
|
||||
|
||||
- [ ] **Step 2: 输出结构化发现清单**
|
||||
|
||||
输出格式:
|
||||
|
||||
```markdown
|
||||
## 组 1: 核心页面 — 扫描结果
|
||||
|
||||
### 🔧 缺陷
|
||||
|
||||
| 优先级 | 文件:行号 | 问题 | 影响 |
|
||||
|--------|----------|------|------|
|
||||
| P1 | pages/bills/index.vue:42 | onMounted 缺少 waitForReady | 登录未完成就发请求,可能 401 |
|
||||
|
||||
### 🎨 UI 问题
|
||||
|
||||
| 优先级 | 文件:行号 | 问题 | 影响 |
|
||||
|--------|----------|------|------|
|
||||
| P2 | pages/index/index.vue:xxx | 无空状态提示 | 新用户看到空白页 |
|
||||
|
||||
### 🚀 功能缺口
|
||||
|
||||
| 优先级 | 文件:行号 | 问题 | 建议 |
|
||||
|--------|----------|------|------|
|
||||
| P2 | stores/transaction.ts | 无请求去重 | 快速点击可能重复提交 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 扫描组 2 — 用户 & 设置
|
||||
|
||||
**Files (18):**
|
||||
- `client/src/pages/profile/index.vue`
|
||||
- `client/src/pages/profile-edit/index.vue`
|
||||
- `client/src/pages/budget/index.vue`
|
||||
- `client/src/pages/category-manage/index.vue`
|
||||
- `client/src/pages/group-manage/index.vue`
|
||||
- `client/src/pages/notifications/index.vue`
|
||||
- `client/src/pages/feedback/index.vue`
|
||||
- `client/src/pages/privacy/index.vue`
|
||||
- `client/src/stores/user.ts`
|
||||
- `client/src/stores/group.ts`
|
||||
- `client/src/stores/filter.ts`
|
||||
- `client/src/stores/notification.ts`
|
||||
- `client/src/stores/config.ts`
|
||||
- `client/src/api/user.ts`
|
||||
- `client/src/api/group.ts`
|
||||
- `client/src/api/notification.ts`
|
||||
- `client/src/api/feedback.ts`
|
||||
- `client/src/api/filter.ts`
|
||||
|
||||
- [ ] **Step 1: 并行读取所有文件**
|
||||
|
||||
使用 Explore agent,读取上述全部 18 个文件,对每个文件按「扫描检查清单」逐项检查。
|
||||
|
||||
- [ ] **Step 2: 输出结构化发现清单**
|
||||
|
||||
格式同 Task 1。
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 扫描组 3 — 管理后台
|
||||
|
||||
**Files (10):**
|
||||
- `client/src/pages/admin/index.vue`
|
||||
- `client/src/pages/admin/users.vue`
|
||||
- `client/src/pages/admin/notifications.vue`
|
||||
- `client/src/pages/admin/feedback.vue`
|
||||
- `client/src/pages/admin/config.vue`
|
||||
- `client/src/pages/admin/logs.vue`
|
||||
- `client/src/pages/admin/track.vue`
|
||||
- `client/src/api/admin.ts`
|
||||
- `client/src/api/config.ts`
|
||||
- `client/src/api/logs.ts`
|
||||
|
||||
- [ ] **Step 1: 并行读取所有文件**
|
||||
|
||||
使用 Explore agent,读取上述全部 10 个文件,对每个文件按「扫描检查清单」逐项检查。
|
||||
|
||||
- [ ] **Step 2: 输出结构化发现清单**
|
||||
|
||||
格式同 Task 1。
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 扫描组 4 — 后端路由 & 基础设施
|
||||
|
||||
**Files (23):**
|
||||
- `server/src/index.ts`
|
||||
- `server/src/middleware/auth.ts`
|
||||
- `server/src/middleware/logger.ts`
|
||||
- `server/src/middleware/requireAdmin.ts`
|
||||
- `server/src/routes/auth.ts`
|
||||
- `server/src/routes/transaction.ts`
|
||||
- `server/src/routes/category.ts`
|
||||
- `server/src/routes/budget.ts`
|
||||
- `server/src/routes/stats.ts`
|
||||
- `server/src/routes/user.ts`
|
||||
- `server/src/routes/group.ts`
|
||||
- `server/src/routes/notification.ts`
|
||||
- `server/src/routes/filter.ts`
|
||||
- `server/src/routes/feedback.ts`
|
||||
- `server/src/routes/config.ts`
|
||||
- `server/src/routes/admin.ts`
|
||||
- `server/src/routes/track.ts`
|
||||
- `server/src/routes/logs.ts`
|
||||
- `server/src/routes/backup.ts`
|
||||
- `server/src/utils/backup.ts`
|
||||
- `server/src/utils/date.ts`
|
||||
- `server/src/db/connection.ts`
|
||||
- `server/src/db/init.ts`
|
||||
|
||||
- [ ] **Step 1: 并行读取所有文件**
|
||||
|
||||
使用 Explore agent,读取上述全部 23 个文件,对每个文件按以下关注点检查:
|
||||
|
||||
**后端专项检查:**
|
||||
- SQL 注入风险(参数化查询)
|
||||
- 输入校验(类型、范围)
|
||||
- 响应格式统一(`{ code, data/message }`)
|
||||
- 错误码规范
|
||||
- LIMIT/OFFSET 参数类型(整数)
|
||||
- 认证/授权逻辑完整性
|
||||
- 日志记录完整性
|
||||
|
||||
- [ ] **Step 2: 输出结构化发现清单**
|
||||
|
||||
格式同 Task 1。
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 扫描组 5 — 公共组件 & 基础设施
|
||||
|
||||
**Files (12):**
|
||||
- `client/src/components/Icon/Icon.vue`
|
||||
- `client/src/components/Numpad/Numpad.vue`
|
||||
- `client/src/components/TransactionItem/TransactionItem.vue`
|
||||
- `client/src/components/Skeleton/Skeleton.vue`
|
||||
- `client/src/components/SaveSuccess/SaveSuccess.vue`
|
||||
- `client/src/components/BudgetBar/BudgetBar.vue`
|
||||
- `client/src/components/CategoryIcon/CategoryIcon.vue`
|
||||
- `client/src/components/ChartWrapper/ChartWrapper.vue`
|
||||
- `client/src/utils/request.ts`
|
||||
- `client/src/utils/format.ts`
|
||||
- `client/src/utils/tracker.ts`
|
||||
- `client/src/config.ts`
|
||||
|
||||
- [ ] **Step 1: 并行读取所有文件**
|
||||
|
||||
使用 Explore agent,读取上述全部 12 个文件,对每个文件按「扫描检查清单」逐项检查。
|
||||
|
||||
- [ ] **Step 2: 输出结构化发现清单**
|
||||
|
||||
格式同 Task 1。
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 汇总 & 排期
|
||||
|
||||
- [ ] **Step 1: 合并 5 组发现清单**
|
||||
|
||||
将所有扫描结果合并为一文档,按优先级分组:
|
||||
|
||||
```markdown
|
||||
# S1 全面扫描 — 汇总报告
|
||||
|
||||
## P0 — 立即修复(N 项)
|
||||
| 组 | 文件 | 问题 |
|
||||
|----|------|------|
|
||||
|
||||
## P1 — 高优先级(N 项)
|
||||
...
|
||||
|
||||
## P2 — 中优先级(N 项)
|
||||
...
|
||||
|
||||
## P3 — 建议改进(N 项)
|
||||
...
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交汇总报告**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/
|
||||
git commit -m "docs: S1 全面扫描汇总报告"
|
||||
```
|
||||
107
docs/superpowers/specs/2026-06-08-s1-scan-findings.md
Normal file
107
docs/superpowers/specs/2026-06-08-s1-scan-findings.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# S1 全面扫描 — 汇总报告
|
||||
|
||||
日期: 2026-06-08
|
||||
|
||||
## 统计总览
|
||||
|
||||
| 优先级 | 数量 | 说明 |
|
||||
|--------|------|------|
|
||||
| P0 | 0 | 无数据丢失/安全漏洞/崩溃风险 |
|
||||
| P1 | 1 | 功能问题 |
|
||||
| P2 | 4 | 体验/规范问题 |
|
||||
| P3 | 7 | 建议改进 |
|
||||
| **合计** | **12** | |
|
||||
|
||||
---
|
||||
|
||||
## P1 — 高优先级(1 项)
|
||||
|
||||
### 1. notifications 页面使用 emoji 违反设计规范
|
||||
|
||||
| 文件 | 行号 | 问题 | 影响 |
|
||||
|------|------|------|------|
|
||||
| [notifications/index.vue](client/src/pages/notifications/index.vue) | 43 | `<text class="pin-badge">📌</text>` 使用 emoji 作为图钉图标 | 设计规范明确禁止 emoji,必须使用 Icon 组件 |
|
||||
|
||||
**修复方向**:将 `📌` 替换为 `<Icon name="pin" ... />`
|
||||
|
||||
---
|
||||
|
||||
## P2 — 中优先级(4 项)
|
||||
|
||||
### 2. stats 趋势条动画缺少 prefers-reduced-motion
|
||||
|
||||
| 文件 | 行号 | 问题 | 影响 |
|
||||
|------|------|------|------|
|
||||
| [stats/index.vue](client/src/pages/stats/index.vue) | 550 | `.trend-bar { transition: width 0.6s ease-out; }` 无 `prefers-reduced-motion` 处理 | 对动画敏感用户不友好 |
|
||||
|
||||
**修复方向**:添加 `@media (prefers-reduced-motion: reduce) { .trend-bar { transition: none; } }`
|
||||
|
||||
### 3. admin/logs 页面日期选择触发双重请求
|
||||
|
||||
| 文件 | 行号 | 问题 | 影响 |
|
||||
|------|------|------|------|
|
||||
| [admin/logs.vue](client/src/pages/admin/logs.vue) | 181-185, 206-209 | `selectDate()` 直接调用 `loadLogs()`,同时 `watch([filterLevel, selectedDate])` 也触发 `loadLogs()`,导致日期变更时请求发两次 | 浪费带宽,可能导致竞态 |
|
||||
|
||||
**修复方向**:`selectDate()` 只设置 `selectedDate.value`,删除手动 `loadLogs()` 调用,由 watcher 统一触发。
|
||||
|
||||
### 4. bills 页面列表参数使用 any 类型
|
||||
|
||||
| 文件 | 行号 | 问题 | 影响 |
|
||||
|------|------|------|------|
|
||||
| [bills/index.vue](client/src/pages/bills/index.vue) | 109, 165 | `txList: any[]` 和 `params: any` 丢失类型安全 | 重构时易引入 bug |
|
||||
|
||||
**修复方向**:使用 `Transaction[]` 和 `TransactionParams` 类型。
|
||||
|
||||
### 5. bills 空状态缺少图标
|
||||
|
||||
| 文件 | 行号 | 问题 | 影响 |
|
||||
|------|------|------|------|
|
||||
| [bills/index.vue](client/src/pages/bills/index.vue) | 82-84 | 空状态只显示文字「暂无账单记录」,无图标 | 与其他页面空状态不一致(其他页面都有 Icon) |
|
||||
|
||||
**修复方向**:添加 `<Icon name="edit" :size="48" color="#BFB3B3" />`
|
||||
|
||||
---
|
||||
|
||||
## P3 — 建议改进(7 项)
|
||||
|
||||
### 6. 多处使用 `any` 类型
|
||||
|
||||
| 文件 | 行号 | 问题 |
|
||||
|------|------|------|
|
||||
| [index/index.vue](client/src/pages/index/index.vue) | 168 | `recentTx` 类型 `any[]`,应为 `Transaction[]` |
|
||||
| [add/index.vue](client/src/pages/add/index.vue) | 139 | `getCurrentPages()` 结果 cast `as any` |
|
||||
| [budget/index.vue](client/src/pages/budget/index.vue) | 93 | `budget` 类型 `any`,应为 `Budget \| null` |
|
||||
| [profile/index.vue](client/src/pages/profile/index.vue) | 329 | `allList` 类型 `any[]`,应为 `Transaction[]` |
|
||||
| [admin/users.vue](client/src/pages/admin/users.vue) | 99 | `params` 类型 `any`,应为具名类型 |
|
||||
| [admin/feedback.vue](client/src/pages/admin/feedback.vue) | 105 | `list` 类型 `any[]`,应使用 Feedback 类型 |
|
||||
|
||||
### 7. stats 页面 loadData / loadTabData 代码重复
|
||||
|
||||
| 文件 | 行号 | 问题 |
|
||||
|------|------|------|
|
||||
| [stats/index.vue](client/src/pages/stats/index.vue) | 245-281 | `loadData` 和 `loadTabData` 有大量重复代码(loadSeq、错误处理),可抽取公共函数 |
|
||||
|
||||
---
|
||||
|
||||
## 总体评价
|
||||
|
||||
代码质量整体良好,主要体现在:
|
||||
|
||||
- ✅ **认证机制完善**:HMAC-SHA256 token + timingSafeEqual,30天过期
|
||||
- ✅ **竞态保护**:bills、stats、notifications 页面均使用 `loadSeq` 防止并发覆盖
|
||||
- ✅ **401 队列重试**:`request.ts` 自动重登录 + 队列重试,设计精良
|
||||
- ✅ **统一设计系统**:SCSS 变量、mixin 复用度高,Claymorphism 风格统一
|
||||
- ✅ **响应格式统一**:后端 API 返回 `{ code, data/message }` 标准格式
|
||||
- ✅ **输入校验**:前后端均有校验(金额范围、必填字段、类型白名单)
|
||||
- ✅ **参数化查询**:后端 SQL 使用 `?` 占位符,无 SQL 注入风险
|
||||
- ✅ **空状态/加载态/错误态**:大部分页面三态齐全
|
||||
- ✅ **小程序适配**:胶囊按钮避让 `capsuleRight` 使用正确
|
||||
- ✅ **数据加载模式**:`waitForReady()` + `initialLoaded` 守卫模式统一
|
||||
- ✅ **动画可访问性**:大部分页面有 `prefers-reduced-motion` 处理
|
||||
|
||||
主要改进方向集中在:
|
||||
1. 消除 emoji(1 处)
|
||||
2. 补全 `prefers-reduced-motion`(1 处)
|
||||
3. 修复重复请求(1 处)
|
||||
4. 统一空状态样式(1 处)
|
||||
5. 减少 `any` 类型使用(6 处)
|
||||
@@ -0,0 +1,110 @@
|
||||
# 小菜记账 — 质量迭代设计
|
||||
|
||||
日期: 2026-06-08
|
||||
|
||||
## 概述
|
||||
|
||||
对「小菜记账」进行全面质量迭代,分四个阶段推进:全面扫描 → 缺陷修复 → UI 交互优化 → 系统功能迭代。
|
||||
|
||||
## 总体方案:分阶段逐层推进
|
||||
|
||||
```
|
||||
S1 全面扫描 → S2 缺陷修复 → S3 UI 优化 → S4 功能迭代
|
||||
```
|
||||
|
||||
每阶段有独立验收标准,后续阶段基于前一阶段的稳定基线。
|
||||
|
||||
---
|
||||
|
||||
## S1: 全面扫描
|
||||
|
||||
### 扫描维度
|
||||
|
||||
对每个文件从三个维度同时检查:
|
||||
|
||||
#### 🔧 缺陷维度
|
||||
- **类型安全**: API 参数类型错误、null/undefined 处理不当、`any` 滥用
|
||||
- **边界条件**: 空数据、空字符串、0 值、数组越界
|
||||
- **竞态条件**: 快速切换页面时未取消的请求
|
||||
- **小程序适配**: 胶囊按钮避让缺失、scroll-view 高度计算错误
|
||||
- **内存泄漏**: 未清理的 timer、watcher、事件监听
|
||||
- **响应格式**: 后端路由是否统一返回 `{ code, data/message }`
|
||||
- **错误码规范**: 是否符合 0=成功, 40001=参数错误, 40300=权限, 40400=不存在, 50000=服务器错误
|
||||
|
||||
#### 🎨 UI 维度
|
||||
- **加载态**: 是否有 loading/skeleton,避免白屏
|
||||
- **空状态**: 是否有友好的空状态提示(图标+文案)
|
||||
- **错误态**: 网络错误/超时是否有用户提示
|
||||
- **触摸反馈**: 按钮/列表项是否有点击态(`:active` 样式)
|
||||
- **动画过渡**: 是否尊重 `prefers-reduced-motion`
|
||||
- **设计一致性**: 颜色、圆角、阴影是否使用 SCSS 变量,而非硬编码
|
||||
- **触摸目标**: 是否 ≥ 44×44px(小程序最小触摸区域)
|
||||
- **emoji 检查**: 是否混用 emoji 代替 Icon 组件(设计规范禁止 emoji)
|
||||
|
||||
#### 🚀 功能维度
|
||||
- **数据完整性**: 分页是否正确、筛选是否生效
|
||||
- **校验完整性**: 前端+后端是否都有输入校验
|
||||
- **错误处理**: catch 块是否有用户提示(而非静默失败)
|
||||
- **数据流**: API → store → 组件数据流是否清晰,避免 prop drilling
|
||||
|
||||
### 扫描分组
|
||||
|
||||
| 扫描组 | 覆盖范围 | 文件数(约) |
|
||||
|--------|---------|------------|
|
||||
| 组 1: 核心页面 | 首页、记账、账单、统计 + 对应 store + API | ~10 |
|
||||
| 组 2: 用户 & 设置 | 个人、编辑资料、预算、分类管理、群组、通知、反馈、隐私 | ~12 |
|
||||
| 组 3: 管理后台 | 仪表盘、用户管理、公告、反馈、配置、日志、埋点 | ~10 |
|
||||
| 组 4: 后端路由 | 15 个路由 + 中间件 + 工具函数 | ~20 |
|
||||
| 组 5: 公共组件 & 基础设施 | Icon、Numpad、TransactionItem、Skeleton、request、tracker 等 | ~10 |
|
||||
|
||||
### 产出物
|
||||
|
||||
每个扫描组输出结构化发现清单,每条发现包含:
|
||||
- **优先级**: P0(立即)/P1(高)/P2(中)/P3(低)
|
||||
- **文件**: 精确到行号
|
||||
- **问题描述**: 清晰说明缺陷/问题
|
||||
- **影响**: 用户可见影响
|
||||
|
||||
所有发现汇总到统一清单,供审核和排期。
|
||||
|
||||
### 优先级定义
|
||||
|
||||
| 级别 | 含义 | 示例 |
|
||||
|------|------|------|
|
||||
| P0 | 数据丢失/安全/崩溃 | 金额计算错误、SQL 注入、白屏 |
|
||||
| P1 | 功能不可用/严重体验问题 | 按钮无响应、数据不刷新、接口报错 |
|
||||
| P2 | 体验不佳/边界情况 | 空状态缺失、加载闪烁、动画卡顿 |
|
||||
| P3 | 建议改进 | 代码整洁、变量命名、注释补充 |
|
||||
|
||||
---
|
||||
|
||||
## S2: 缺陷修复(扫描后细化)
|
||||
|
||||
- 按 P0 → P1 → P2 顺序修复
|
||||
- 每个修复前先写测试用例复现
|
||||
- 修复后验证已有测试通过
|
||||
|
||||
---
|
||||
|
||||
## S3: UI 交互优化(扫描后细化)
|
||||
|
||||
- 统一加载/空/错误状态组件
|
||||
- 统一触摸反馈模式
|
||||
- 动画一致性检查
|
||||
- 无障碍改进
|
||||
|
||||
---
|
||||
|
||||
## S4: 系统功能迭代(扫描后细化)
|
||||
|
||||
- 基于扫描发现的功能缺口
|
||||
- 新功能需写 spec → plan → implement
|
||||
|
||||
---
|
||||
|
||||
## 不涉及的范围
|
||||
|
||||
- 不修改 `dist/` 目录
|
||||
- 不修改 lock 文件
|
||||
- 不做无关重构
|
||||
- 不做数据库结构大改(如需迁移需单独评审)
|
||||
@@ -1,24 +1,21 @@
|
||||
# Server
|
||||
PORT=3000
|
||||
|
||||
# Security
|
||||
TOKEN_SECRET=your_random_secret_here
|
||||
|
||||
# CORS (comma-separated origins, empty = allow all)
|
||||
CORS_ORIGINS=
|
||||
|
||||
# MySQL
|
||||
DB_HOST=your_mysql_host_here
|
||||
DB_USER=your_mysql_user_here
|
||||
DB_PASSWORD=your_mysql_password_here
|
||||
DB_NAME=your_mysql_name_here
|
||||
DB_HOST=your_db_host
|
||||
DB_USER=your_db_user
|
||||
DB_PASSWORD=your_db_password
|
||||
DB_NAME=xiaocai
|
||||
|
||||
# Uploads
|
||||
UPLOAD_DIR=./uploads
|
||||
|
||||
# Backup
|
||||
BACKUP_DIR=/var/backups/xiaocai
|
||||
BACKUP_DIR=./backups
|
||||
|
||||
# Token (REQUIRED - server will not start with default value)
|
||||
TOKEN_SECRET=change-me-to-a-secure-random-string
|
||||
|
||||
# WeChat Mini Program
|
||||
WX_APPID=your_wx_appid_here
|
||||
WX_SECRET=your_wx_secret_here
|
||||
WX_APPID=your_wx_appid
|
||||
WX_SECRET=your_wx_secret
|
||||
|
||||
7
server/jest.config.js
Normal file
7
server/jest.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/tests'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
}
|
||||
3948
server/package-lock.json
generated
3948
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc && shx cp src/db/*.sql dist/db/",
|
||||
"start": "node dist/index.js",
|
||||
"db:init": "tsx src/db/init.ts"
|
||||
"db:init": "tsx src/db/init.ts",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
@@ -14,14 +15,19 @@
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.9.0"
|
||||
"mysql2": "^3.9.0",
|
||||
"node-cron": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"jest": "^29.7.0",
|
||||
"shx": "^0.4.0",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-jest": "^29.4.11",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
|
||||
22
server/src/config/token.ts
Normal file
22
server/src/config/token.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Token 安全配置 — 集中管理 TOKEN_SECRET 和启动校验
|
||||
*
|
||||
* 所有需要 TOKEN_SECRET 的模块统一从此处导入,
|
||||
* 避免各文件内联定义导致的不一致风险。
|
||||
*/
|
||||
|
||||
/** 从环境变量读取 TOKEN_SECRET,无默认值(启动时强制校验) */
|
||||
export const TOKEN_SECRET = process.env.TOKEN_SECRET || ''
|
||||
|
||||
/** 启动时检查 TOKEN_SECRET 安全性,使用默认值则拒绝启动 */
|
||||
export function checkTokenSecret(): void {
|
||||
const defaults = [
|
||||
'xiaocai-token-secret-change-in-production',
|
||||
'change-me-to-a-secure-random-string',
|
||||
'',
|
||||
]
|
||||
if (defaults.includes(TOKEN_SECRET) || TOKEN_SECRET.length < 32) {
|
||||
console.error('[Security] FATAL: TOKEN_SECRET is missing, using a default, or shorter than 32 chars. Set a secure TOKEN_SECRET (min 32 chars) in .env before starting the server.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import mysql from 'mysql2/promise'
|
||||
|
||||
// 必需环境变量校验
|
||||
const REQUIRED_ENV = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME']
|
||||
const missing = REQUIRED_ENV.filter(key => !process.env[key])
|
||||
if (missing.length > 0) {
|
||||
console.error(`[DB] FATAL: Missing required environment variables: ${missing.join(', ')}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'xiaocai',
|
||||
password: process.env.DB_PASSWORD || 'xiaocai123',
|
||||
database: process.env.DB_NAME || 'xiaocai',
|
||||
host: process.env.DB_HOST!,
|
||||
user: process.env.DB_USER!,
|
||||
password: process.env.DB_PASSWORD!,
|
||||
database: process.env.DB_NAME!,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
@@ -14,4 +22,9 @@ const pool = mysql.createPool({
|
||||
console.error('[DB] Pool error:', err?.message || err)
|
||||
})
|
||||
|
||||
/** 获取单个连接(用于事务) */
|
||||
export async function getConnection() {
|
||||
return pool.getConnection()
|
||||
}
|
||||
|
||||
export default pool
|
||||
|
||||
@@ -148,16 +148,211 @@ async function runMigrations(conn: mysql.Connection) {
|
||||
await conn.query('ALTER TABLE notifications ADD INDEX idx_pinned (is_pinned, created_at)')
|
||||
await conn.query('ALTER TABLE notifications ADD INDEX idx_expire (expire_at)')
|
||||
}
|
||||
|
||||
// 公告已读记录表(解决系统/群组公告已读状态共享问题)
|
||||
const hasNotificationReads = await tableExists(conn, 'notification_reads')
|
||||
if (!hasNotificationReads) {
|
||||
console.log('[DB] Migrating: creating notification_reads table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||
notification_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (notification_id, user_id),
|
||||
INDEX idx_user (user_id),
|
||||
FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
|
||||
// users 表添加 slogan(个性签名)
|
||||
const hasSlogan = await columnExists(conn, 'users', 'slogan')
|
||||
if (!hasSlogan) {
|
||||
console.log('[DB] Migrating: adding slogan to users')
|
||||
await conn.query("ALTER TABLE users ADD COLUMN slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名' AFTER avatar_url")
|
||||
}
|
||||
|
||||
// 系统配置表
|
||||
const hasSysConfig = await tableExists(conn, 'sys_config')
|
||||
if (!hasSysConfig) {
|
||||
console.log('[DB] Migrating: creating sys_config table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS sys_config (
|
||||
config_key VARCHAR(50) PRIMARY KEY COMMENT '配置键',
|
||||
config_value TEXT NOT NULL COMMENT '配置值',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
// 插入默认配置
|
||||
await conn.query(`
|
||||
INSERT IGNORE INTO sys_config (config_key, config_value) VALUES
|
||||
('app_name', '小菜记账'),
|
||||
('app_version', '1.0.0'),
|
||||
('app_slogan', '温暖 x 轻松的个人记账小程序'),
|
||||
('app_description', '让记账从负担变成享受')
|
||||
`)
|
||||
}
|
||||
|
||||
// 确保 app_name 配置存在(兼容已有数据库)
|
||||
const [appNameRows] = await conn.query(
|
||||
"SELECT COUNT(*) as cnt FROM sys_config WHERE config_key = 'app_name'"
|
||||
)
|
||||
if ((appNameRows as any[])[0].cnt === 0) {
|
||||
await conn.query("INSERT INTO sys_config (config_key, config_value) VALUES ('app_name', '小菜记账')")
|
||||
}
|
||||
|
||||
// 反馈表
|
||||
const hasFeedbacks = await tableExists(conn, 'feedbacks')
|
||||
if (!hasFeedbacks) {
|
||||
console.log('[DB] Migrating: creating feedbacks table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
type ENUM('bug', 'feature', 'ux', 'other') NOT NULL DEFAULT 'other' COMMENT '反馈类型',
|
||||
content TEXT NOT NULL COMMENT '反馈内容',
|
||||
contact VARCHAR(100) DEFAULT '' COMMENT '联系方式',
|
||||
status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态',
|
||||
admin_reply TEXT COMMENT '管理员回复',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created (created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
|
||||
// 埋点事件表
|
||||
const hasTrackEvents = await tableExists(conn, 'track_events')
|
||||
if (!hasTrackEvents) {
|
||||
console.log('[DB] Migrating: creating track_events table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS track_events (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
event VARCHAR(50) NOT NULL COMMENT '事件类型',
|
||||
page VARCHAR(100) DEFAULT '' COMMENT '页面路径',
|
||||
data JSON DEFAULT NULL COMMENT '事件数据',
|
||||
user_id INT DEFAULT NULL COMMENT '用户ID',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_event (event),
|
||||
INDEX idx_created (created_at),
|
||||
INDEX idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
|
||||
// 周期账单模板表
|
||||
const hasRecurring = await tableExists(conn, 'recurring_templates')
|
||||
if (!hasRecurring) {
|
||||
console.log('[DB] Migrating: creating recurring_templates table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS recurring_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
amount INT NOT NULL COMMENT '单位:分',
|
||||
type ENUM('expense', 'income') NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
note VARCHAR(200) DEFAULT '',
|
||||
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
|
||||
next_date DATE NOT NULL COMMENT '下次生成日期',
|
||||
end_date DATE DEFAULT NULL COMMENT '截止日期,NULL=永久',
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
group_id INT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_next_date (next_date),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
|
||||
// transactions 表添加 recurring_id
|
||||
const hasRecurringId = await columnExists(conn, 'transactions', 'recurring_id')
|
||||
if (!hasRecurringId) {
|
||||
console.log('[DB] Migrating: adding recurring_id to transactions')
|
||||
await conn.query("ALTER TABLE transactions ADD COLUMN recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID' AFTER group_id")
|
||||
}
|
||||
|
||||
// 标签表
|
||||
const hasTags = await tableExists(conn, 'tags')
|
||||
if (!hasTags) {
|
||||
console.log('[DB] Migrating: creating tags table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(20) NOT NULL COMMENT '标签名称',
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_name (user_id, name),
|
||||
INDEX idx_user (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
|
||||
// 交易-标签关联表
|
||||
const hasTransactionTags = await tableExists(conn, 'transaction_tags')
|
||||
if (!hasTransactionTags) {
|
||||
console.log('[DB] Migrating: creating transaction_tags table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS transaction_tags (
|
||||
transaction_id INT NOT NULL,
|
||||
tag_id INT NOT NULL,
|
||||
PRIMARY KEY (transaction_id, tag_id),
|
||||
INDEX idx_tag (tag_id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
|
||||
// users 表添加软删除字段
|
||||
const hasDeletedAt = await columnExists(conn, 'users', 'deleted_at')
|
||||
if (!hasDeletedAt) {
|
||||
console.log('[DB] Migrating: adding deleted_at to users')
|
||||
await conn.query("ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间(NULL=正常)'")
|
||||
await conn.query('ALTER TABLE users ADD INDEX idx_users_deleted (deleted_at)')
|
||||
console.log('[DB] users.deleted_at added')
|
||||
}
|
||||
|
||||
// Refresh Token 表
|
||||
const hasRefreshTokens = await tableExists(conn, 'refresh_tokens')
|
||||
if (!hasRefreshTokens) {
|
||||
console.log('[DB] Migrating: creating refresh_tokens table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
|
||||
expires_at DATETIME NOT NULL COMMENT '过期时间',
|
||||
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间(NULL=有效)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_hash (token_hash),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
console.log('[DB] refresh_tokens table created')
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDatabase() {
|
||||
const dbName = process.env.DB_NAME || 'xiaocai'
|
||||
const dbName = process.env.DB_NAME!
|
||||
const dbHost = process.env.DB_HOST!
|
||||
const dbUser = process.env.DB_USER!
|
||||
const dbPassword = process.env.DB_PASSWORD!
|
||||
|
||||
// 先用不指定数据库的连接,确保数据库存在
|
||||
const bootstrap = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'xiaocai',
|
||||
password: process.env.DB_PASSWORD || 'xiaocai123',
|
||||
host: dbHost,
|
||||
user: dbUser,
|
||||
password: dbPassword,
|
||||
multipleStatements: true,
|
||||
})
|
||||
|
||||
@@ -166,9 +361,9 @@ export async function initDatabase() {
|
||||
|
||||
// 用独立连接执行建表和填充(需要 multipleStatements)
|
||||
const initConn = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'xiaocai',
|
||||
password: process.env.DB_PASSWORD || 'xiaocai123',
|
||||
host: dbHost,
|
||||
user: dbUser,
|
||||
password: dbPassword,
|
||||
database: dbName,
|
||||
multipleStatements: true,
|
||||
})
|
||||
|
||||
@@ -14,3 +14,22 @@ ALTER TABLE budgets ADD FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CAS
|
||||
|
||||
-- categories 表确认 user_id 存在(旧版可能也没有)
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS user_id INT DEFAULT 0 COMMENT '0=默认分类, 其他=用户自定义' AFTER id;
|
||||
|
||||
-- v2 迭代迁移
|
||||
|
||||
-- users 表:新增软删除字段
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间(NULL=正常)';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
|
||||
|
||||
-- 新增 refresh_tokens 表
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
|
||||
expires_at DATETIME NOT NULL COMMENT '过期时间',
|
||||
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间(NULL=有效)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_hash (token_hash),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -4,9 +4,12 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
session_key VARCHAR(100),
|
||||
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
|
||||
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
||||
slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名',
|
||||
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
|
||||
deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间(NULL=正常)',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_users_deleted (deleted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
@@ -31,7 +34,8 @@ CREATE TABLE IF NOT EXISTS transactions (
|
||||
category_id INT,
|
||||
note VARCHAR(200),
|
||||
date DATE NOT NULL,
|
||||
group_id INT DEFAULT NULL COMMENT '群组标签,NULL=纯个人记录',
|
||||
group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)',
|
||||
recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_date (user_id, date),
|
||||
@@ -81,7 +85,7 @@ CREATE TABLE IF NOT EXISTS notifications (
|
||||
type ENUM('system', 'group', 'personal') NOT NULL,
|
||||
title VARCHAR(100) NOT NULL,
|
||||
content TEXT,
|
||||
is_read TINYINT(1) DEFAULT 0,
|
||||
is_read TINYINT(1) DEFAULT 0 COMMENT '仅用于个人通知,系统/群组公告使用 notification_reads 表',
|
||||
is_pinned TINYINT(1) DEFAULT 0 COMMENT '是否置顶',
|
||||
is_urgent TINYINT(1) DEFAULT 0 COMMENT '是否强提醒弹窗',
|
||||
publish_at TIMESTAMP NULL COMMENT '定时发布时间(NULL=立即发布)',
|
||||
@@ -95,6 +99,17 @@ CREATE TABLE IF NOT EXISTS notifications (
|
||||
INDEX idx_expire (expire_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 公告已读记录表(解决系统/群组公告已读状态共享问题)
|
||||
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||
notification_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (notification_id, user_id),
|
||||
INDEX idx_user (user_id),
|
||||
FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_filters (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
@@ -103,3 +118,89 @@ CREATE TABLE IF NOT EXISTS saved_filters (
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
type ENUM('bug', 'feature', 'ux', 'other') NOT NULL DEFAULT 'other' COMMENT '反馈类型',
|
||||
content TEXT NOT NULL COMMENT '反馈内容',
|
||||
contact VARCHAR(100) DEFAULT '' COMMENT '联系方式',
|
||||
status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态',
|
||||
admin_reply TEXT COMMENT '管理员回复',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created (created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_config (
|
||||
config_key VARCHAR(50) PRIMARY KEY COMMENT '配置键',
|
||||
config_value TEXT NOT NULL COMMENT '配置值',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS track_events (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
event VARCHAR(50) NOT NULL COMMENT '事件类型',
|
||||
page VARCHAR(100) DEFAULT '' COMMENT '页面路径',
|
||||
data JSON DEFAULT NULL COMMENT '事件数据',
|
||||
user_id INT DEFAULT NULL COMMENT '用户ID',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_event (event),
|
||||
INDEX idx_created (created_at),
|
||||
INDEX idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recurring_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
amount INT NOT NULL COMMENT '单位:分',
|
||||
type ENUM('expense', 'income') NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
note VARCHAR(200) DEFAULT '',
|
||||
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
|
||||
next_date DATE NOT NULL COMMENT '下次生成日期',
|
||||
end_date DATE DEFAULT NULL COMMENT '截止日期,NULL=永久',
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
group_id INT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_next_date (next_date),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(20) NOT NULL COMMENT '标签名称',
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_name (user_id, name),
|
||||
INDEX idx_user (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transaction_tags (
|
||||
transaction_id INT NOT NULL,
|
||||
tag_id INT NOT NULL,
|
||||
PRIMARY KEY (transaction_id, tag_id),
|
||||
INDEX idx_tag (tag_id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
|
||||
expires_at DATETIME NOT NULL COMMENT '过期时间',
|
||||
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间(NULL=有效)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_hash (token_hash),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -15,3 +15,10 @@ INSERT IGNORE INTO categories (user_id, name, icon, color, type, sort_order) VAL
|
||||
(0, '副业', 'TrendingUp', '#7BC67E', 'income', 2),
|
||||
(0, '理财', 'PieChart', '#7BC67E', 'income', 3),
|
||||
(0, '其他收入', 'Plus', '#BFB3B3', 'income', 4);
|
||||
|
||||
-- 系统配置
|
||||
INSERT IGNORE INTO sys_config (config_key, config_value) VALUES
|
||||
('app_name', '小菜记账'),
|
||||
('app_version', '1.0.0'),
|
||||
('app_slogan', '温暖 x 轻松的个人记账小程序'),
|
||||
('app_description', '让记账从负担变成享受');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user