Compare commits
53 Commits
a35689cdda
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 324a3ec5c9 | |||
| 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 |
38
CLAUDE.md
38
CLAUDE.md
@@ -68,10 +68,14 @@ client/src/
|
|||||||
│ ├── stats/ # 统计分析
|
│ ├── stats/ # 统计分析
|
||||||
│ ├── bills/ # 全部账单 (下拉刷新/上拉加载)
|
│ ├── bills/ # 全部账单 (下拉刷新/上拉加载)
|
||||||
│ ├── profile/ # 我的(身份切换入口)
|
│ ├── profile/ # 我的(身份切换入口)
|
||||||
│ ├── profile-edit/ # 编辑资料(昵称+头像)
|
│ ├── profile-edit/ # 编辑资料(昵称+头像+签名)
|
||||||
│ ├── group-manage/ # 群组管理(创建/加入/退出/解散)
|
│ ├── group-manage/ # 群组管理(创建/加入/退出/解散)
|
||||||
│ ├── category-manage/ # 分类管理
|
│ ├── category-manage/ # 分类管理
|
||||||
│ └── budget/ # 预算设置
|
│ ├── budget/ # 预算设置
|
||||||
|
│ ├── notifications/ # 通知中心
|
||||||
|
│ ├── feedback/ # 意见反馈
|
||||||
|
│ ├── privacy/ # 隐私政策
|
||||||
|
│ └── admin/ # 管理后台 (index/users/notifications/feedback/config)
|
||||||
├── components/ # 公共组件
|
├── components/ # 公共组件
|
||||||
│ ├── BudgetBar/ # 预算进度条
|
│ ├── BudgetBar/ # 预算进度条
|
||||||
│ ├── CategoryIcon/ # 分类图标 (首字+颜色)
|
│ ├── CategoryIcon/ # 分类图标 (首字+颜色)
|
||||||
@@ -96,18 +100,26 @@ client/src/
|
|||||||
│ ├── stats.ts # 统计接口
|
│ ├── stats.ts # 统计接口
|
||||||
│ ├── user.ts # 用户信息接口 (含头像上传)
|
│ ├── user.ts # 用户信息接口 (含头像上传)
|
||||||
│ ├── group.ts # 群组接口
|
│ ├── group.ts # 群组接口
|
||||||
|
│ ├── notification.ts # 通知接口
|
||||||
|
│ ├── filter.ts # 筛选方案接口
|
||||||
|
│ ├── feedback.ts # 反馈接口
|
||||||
|
│ ├── config.ts # 系统配置接口
|
||||||
|
│ ├── admin.ts # 管理后台接口
|
||||||
│ └── index.ts # barrel 导出
|
│ └── index.ts # barrel 导出
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── format.ts # 金额/日期格式化
|
│ ├── format.ts # 金额/日期格式化
|
||||||
│ ├── request.ts # HTTP 请求封装 (401 自动重登录+队列重试)
|
│ ├── request.ts # HTTP 请求封装 (401 自动重登录+队列重试)
|
||||||
│ ├── system.ts # 系统信息 (statusBarHeight, capsuleRight)
|
│ ├── system.ts # 系统信息 (statusBarHeight, capsuleRight)
|
||||||
│ └── app-ready.ts # App 启动就绪机制 (waitForReady)
|
│ ├── app-ready.ts # App 启动就绪机制 (waitForReady)
|
||||||
|
│ └── tracker.ts # 前端埋点 (页面访问/操作/错误)
|
||||||
├── config.ts # 全局配置 (API_BASE)
|
├── config.ts # 全局配置 (API_BASE)
|
||||||
└── static/icons/ # tabBar 图标 (PNG)
|
└── static/icons/ # tabBar 图标 (PNG)
|
||||||
|
|
||||||
server/src/
|
server/src/
|
||||||
├── index.ts # Express 入口 (CORS, JSON, 静态文件, 路由注册)
|
├── index.ts # Express 入口 (CORS, JSON, 静态文件, 路由注册)
|
||||||
├── middleware/auth.ts # HMAC token 验证 (timingSafeEqual)
|
├── middleware/
|
||||||
|
│ ├── auth.ts # HMAC token 验证 (timingSafeEqual)
|
||||||
|
│ └── logger.ts # 请求日志 (记录到 logs/ 目录)
|
||||||
├── routes/
|
├── routes/
|
||||||
│ ├── auth.ts # 登录 / token 签发 (返回用户信息)
|
│ ├── auth.ts # 登录 / token 签发 (返回用户信息)
|
||||||
│ ├── transaction.ts # 交易 CRUD (支持 group_id 视图切换)
|
│ ├── transaction.ts # 交易 CRUD (支持 group_id 视图切换)
|
||||||
@@ -116,6 +128,12 @@ server/src/
|
|||||||
│ ├── stats.ts # 统计查询 (支持 group_id)
|
│ ├── stats.ts # 统计查询 (支持 group_id)
|
||||||
│ ├── user.ts # 用户信息 + 头像上传
|
│ ├── user.ts # 用户信息 + 头像上传
|
||||||
│ ├── group.ts # 群组 CRUD (创建/加入/退出/解散)
|
│ ├── group.ts # 群组 CRUD (创建/加入/退出/解散)
|
||||||
|
│ ├── notification.ts # 通知/公告 CRUD
|
||||||
|
│ ├── filter.ts # 筛选方案 CRUD
|
||||||
|
│ ├── feedback.ts # 用户反馈 (提交/查询/处理)
|
||||||
|
│ ├── config.ts # 系统配置 (公开读/管理员写)
|
||||||
|
│ ├── admin.ts # 管理后台 (仪表盘/用户管理)
|
||||||
|
│ ├── track.ts # 埋点数据接收
|
||||||
│ └── backup.ts # 备份 API
|
│ └── backup.ts # 备份 API
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── backup.ts # Node.js 数据库备份 (gzip)
|
│ ├── backup.ts # Node.js 数据库备份 (gzip)
|
||||||
@@ -124,16 +142,21 @@ server/src/
|
|||||||
├── connection.ts # MySQL 连接池
|
├── connection.ts # MySQL 连接池
|
||||||
├── init.ts # 建表 + seed + 迁移 (幂等)
|
├── init.ts # 建表 + seed + 迁移 (幂等)
|
||||||
├── schema.sql # 表结构
|
├── schema.sql # 表结构
|
||||||
└── seed.sql # 默认分类数据
|
└── seed.sql # 默认分类数据 + 系统配置
|
||||||
```
|
```
|
||||||
|
|
||||||
### 数据库表
|
### 数据库表
|
||||||
- `users` — 用户 (openid, nickname, avatar_url)
|
- `users` — 用户 (openid, nickname, avatar_url, slogan, role)
|
||||||
- `categories` — 分类 (user_id=0 默认, 其他=自定义)
|
- `categories` — 分类 (user_id=0 默认, 其他=自定义)
|
||||||
- `transactions` — 交易记录 (user_id, group_id, amount 分)
|
- `transactions` — 交易记录 (user_id, group_id, amount 分)
|
||||||
- `budgets` — 预算 (user_id, month, amount)
|
- `budgets` — 预算 (user_id, month, amount)
|
||||||
- `groups` — 群组 (name, invite_code, created_by)
|
- `groups` — 群组 (name, invite_code, created_by)
|
||||||
- `group_members` — 群组成员 (group_id, user_id, role)
|
- `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)
|
- 群组仅做数据聚合,记录始终属于记录者 (user_id)
|
||||||
- 个人视图: `WHERE user_id = 我` (所有记录)
|
- 个人视图: `WHERE user_id = 我` (所有记录)
|
||||||
- 群组视图: `WHERE group_id = X AND user_id IN (当前成员)`
|
- 群组视图: `WHERE user_id IN (群组成员)` — 统计所有群组成员的个人账单
|
||||||
|
- 群组账单 = 所有群组成员的个人账单之和
|
||||||
- 退出群组: 该用户的 group_id 设为 NULL,记录回到个人视图
|
- 退出群组: 该用户的 group_id 设为 NULL,记录回到个人视图
|
||||||
- 身份切换: 「我的」页底部弹窗选择,全局生效
|
- 身份切换: 「我的」页底部弹窗选择,全局生效
|
||||||
|
|
||||||
|
|||||||
77
DEV.md
77
DEV.md
@@ -33,6 +33,82 @@
|
|||||||
- API 地址统一在 `config.ts` 中配置,不要在各文件中硬编码
|
- API 地址统一在 `config.ts` 中配置,不要在各文件中硬编码
|
||||||
- 头像等用户文件 URL 只存文件名,前端拼接 `API_BASE + 路径 + 文件名`
|
- 头像等用户文件 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 规则
|
||||||
|
|
||||||
- Never 修改 dist/ 目录
|
- Never 修改 dist/ 目录
|
||||||
@@ -43,6 +119,7 @@
|
|||||||
- Never 使用 git stash
|
- Never 使用 git stash
|
||||||
- Never 切换分支
|
- Never 切换分支
|
||||||
- Never 过度封装,保持代码简洁明了
|
- Never 过度封装,保持代码简洁明了
|
||||||
|
- Never 未确定需求就开始实现
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,51 @@
|
|||||||
import { onLaunch } from '@dcloudio/uni-app'
|
import { onLaunch } from '@dcloudio/uni-app'
|
||||||
import { wxLogin, demoLogin } from '@/api/auth'
|
import { wxLogin, demoLogin } from '@/api/auth'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { saveLoginResult } from '@/utils/request'
|
import { saveLoginResult } from '@/utils/request'
|
||||||
import { markReady } from '@/utils/app-ready'
|
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(() => {
|
onLaunch(() => {
|
||||||
|
// 初始化错误追踪
|
||||||
|
setupErrorTracking()
|
||||||
|
trackAction('app_launch')
|
||||||
|
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
groupStore.init()
|
groupStore.init()
|
||||||
|
configStore.fetchConfig()
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
uni.login({
|
uni.login({
|
||||||
provider: 'weixin',
|
provider: 'weixin',
|
||||||
success: async (loginRes) => {
|
success: async (loginRes) => {
|
||||||
if (loginRes.code) {
|
if (loginRes.code) {
|
||||||
try {
|
const success = await loginWithRetry(() => wxLogin(loginRes.code))
|
||||||
saveLoginResult(await wxLogin(loginRes.code))
|
if (success) {
|
||||||
groupStore.fetchGroups()
|
groupStore.fetchGroups()
|
||||||
console.log('[Auth] Login success')
|
console.log('[Auth] Login success')
|
||||||
} catch (e) {
|
} else {
|
||||||
console.error('[Auth] Login failed:', e)
|
uni.showToast({ title: '登录失败,请重试', icon: 'none', duration: 3000 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
markReady()
|
markReady()
|
||||||
@@ -30,13 +57,15 @@ onLaunch(() => {
|
|||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
const token = uni.getStorageSync('xc:token')
|
const token = uni.getStorageSync('xc:token')
|
||||||
if (!token) {
|
if (!token) {
|
||||||
demoLogin().then((data) => {
|
loginWithRetry(demoLogin).then((success) => {
|
||||||
saveLoginResult(data)
|
if (success) {
|
||||||
groupStore.fetchGroups()
|
groupStore.fetchGroups()
|
||||||
console.log('[Auth] H5 demo login success')
|
console.log('[Auth] H5 demo login success')
|
||||||
}).catch((e) => {
|
} else {
|
||||||
console.error('[Auth] H5 demo login failed:', e)
|
uni.showToast({ title: '登录失败,请刷新页面', icon: 'none', duration: 3000 })
|
||||||
}).finally(() => { markReady() })
|
}
|
||||||
|
markReady()
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
groupStore.fetchGroups()
|
groupStore.fetchGroups()
|
||||||
markReady()
|
markReady()
|
||||||
@@ -49,10 +78,15 @@ onLaunch(() => {
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import './uni.scss';
|
@import './uni.scss';
|
||||||
|
|
||||||
|
// 全局盒模型
|
||||||
|
view, text, image, input, textarea, scroll-view, picker {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
page {
|
page {
|
||||||
background-color: #FFF8F0;
|
background-color: $bg;
|
||||||
font-family: 'Nunito', 'PingFang SC', sans-serif;
|
font-family: 'Nunito', 'PingFang SC', sans-serif;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export interface Dashboard {
|
|||||||
growthPercent: number
|
growthPercent: number
|
||||||
lastMonthTxCount: number
|
lastMonthTxCount: number
|
||||||
lastMonthTxAmount: number
|
lastMonthTxAmount: number
|
||||||
|
monthlyExpense?: number
|
||||||
|
monthlyIncome?: number
|
||||||
|
lastMonthExpense?: number
|
||||||
|
lastMonthIncome?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户信息(管理员视角) */
|
/** 用户信息(管理员视角) */
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface LoginResult {
|
|||||||
userId: number
|
userId: number
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar_url: string
|
avatar_url: string
|
||||||
|
role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** H5 演示登录 */
|
/** H5 演示登录 */
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ export function getBudget(month?: string, group_id?: number | null) {
|
|||||||
|
|
||||||
/** 设置预算 */
|
/** 设置预算 */
|
||||||
export function setBudget(amount: number, month: string) {
|
export function setBudget(amount: number, month: string) {
|
||||||
return request<{ id: number }>({ url: '/budget', method: 'POST', data: { amount, month } })
|
return request<void>({ url: '/budget', method: 'POST', data: { amount, month } })
|
||||||
}
|
}
|
||||||
|
|||||||
11
client/src/api/config.ts
Normal file
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 })
|
||||||
|
}
|
||||||
35
client/src/api/feedback.ts
Normal file
35
client/src/api/feedback.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
/** 反馈条目 */
|
||||||
|
export interface Feedback {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
nickname?: string
|
||||||
|
avatar_url?: string
|
||||||
|
type: string
|
||||||
|
content: string
|
||||||
|
contact: string
|
||||||
|
status: 'pending' | 'processed' | 'ignored'
|
||||||
|
admin_reply?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交反馈 */
|
||||||
|
export function submitFeedback(data: {
|
||||||
|
type: string
|
||||||
|
content: string
|
||||||
|
contact?: string
|
||||||
|
}) {
|
||||||
|
return request({ url: '/feedback', method: 'POST', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取反馈列表(管理员) */
|
||||||
|
export function getFeedbackList(params?: { page?: number; pageSize?: number; status?: string }) {
|
||||||
|
return request<{ list: Feedback[]; total: number; page: number; pageSize: number }>({ url: '/feedback', data: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新反馈状态(管理员) */
|
||||||
|
export function updateFeedbackStatus(id: number, status: Feedback['status'], admin_reply?: string) {
|
||||||
|
return request({ url: `/feedback/${id}/status`, method: 'PUT', data: { status, admin_reply } })
|
||||||
|
}
|
||||||
91
client/src/api/logs.ts
Normal file
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 { request } from '@/utils/request'
|
||||||
|
import { API_BASE } from '@/config'
|
||||||
|
|
||||||
/** 通知 */
|
/** 通知 */
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
@@ -64,6 +65,42 @@ export function markAllRead() {
|
|||||||
return request({ url: '/notifications/read-all', method: 'PUT' })
|
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) {
|
export function publishNotification(data: PublishParams) {
|
||||||
return request<{ id: number }>({ url: '/notifications', method: 'POST', data })
|
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' })
|
||||||
|
}
|
||||||
@@ -41,3 +41,14 @@ export function getCategoryStats(month?: string, type: string = 'expense', group
|
|||||||
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) {
|
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) {
|
||||||
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } })
|
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 智能分类建议结果 */
|
||||||
|
export interface CategorySuggestion {
|
||||||
|
category: { id: number; name: string; color: string } | null
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据金额和备注推荐分类 */
|
||||||
|
export function suggestCategory(amount: number, type: string, note?: string) {
|
||||||
|
return request<CategorySuggestion>({ url: '/stats/suggest-category', data: { amount, type, note } })
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface UserInfo {
|
|||||||
id: number
|
id: number
|
||||||
nickname: string
|
nickname: string
|
||||||
avatar_url: string
|
avatar_url: string
|
||||||
|
slogan: string
|
||||||
role: 'user' | 'admin'
|
role: 'user' | 'admin'
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -15,9 +16,11 @@ export function getUserInfo() {
|
|||||||
return request<UserInfo>({ url: '/user/me' })
|
return request<UserInfo>({ url: '/user/me' })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新昵称 */
|
/** 更新昵称/签名 */
|
||||||
export function updateNickname(nickname: string) {
|
export function updateNickname(nickname: string, slogan?: string) {
|
||||||
return request({ url: '/user/me', method: 'PUT', data: { nickname } })
|
const data: any = { nickname }
|
||||||
|
if (slogan !== undefined) data.slogan = slogan
|
||||||
|
return request({ url: '/user/me', method: 'PUT', data })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传头像(返回新 URL) */
|
/** 上传头像(返回新 URL) */
|
||||||
|
|||||||
272
client/src/components/AmountEditor/AmountEditor.vue
Normal file
272
client/src/components/AmountEditor/AmountEditor.vue
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
<template>
|
||||||
|
<view class="amount-editor" :class="[`amount-editor--${size}`, { 'amount-editor--focused': innerFocused }]">
|
||||||
|
<view class="amount-display" @tap="focusAtEnd">
|
||||||
|
<text class="currency">¥</text>
|
||||||
|
<view class="amount-value">
|
||||||
|
<template v-if="displayChars.length > 0">
|
||||||
|
<template v-for="(char, index) in displayChars" :key="`${char}-${index}`">
|
||||||
|
<text v-if="innerFocused && cursorIndex === index" class="cursor"></text>
|
||||||
|
<text class="amount-char" @tap.stop="focusAt(index + 1)">{{ char }}</text>
|
||||||
|
</template>
|
||||||
|
<text v-if="innerFocused && cursorIndex === displayChars.length" class="cursor"></text>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<text class="placeholder" @tap.stop="focusAtEnd">{{ placeholder }}</text>
|
||||||
|
<text v-if="innerFocused" class="cursor"></text>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
<text v-if="unit" class="unit">{{ unit }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<Numpad
|
||||||
|
v-model="innerValue"
|
||||||
|
v-model:cursorIndex="cursorIndex"
|
||||||
|
:max="max"
|
||||||
|
:fixed="fixed"
|
||||||
|
:visible="innerFocused && visible"
|
||||||
|
@confirm="emit('confirm')"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import Numpad from '@/components/Numpad/Numpad.vue'
|
||||||
|
import { clampCursorIndex } from '@/components/AmountEditor/amount-edit'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue?: string
|
||||||
|
max?: number
|
||||||
|
fixed?: boolean
|
||||||
|
visible?: boolean
|
||||||
|
autoFocus?: boolean
|
||||||
|
size?: 'small' | 'medium' | 'large'
|
||||||
|
placeholder?: string
|
||||||
|
unit?: string
|
||||||
|
}>(), {
|
||||||
|
modelValue: '',
|
||||||
|
max: 9999999.99,
|
||||||
|
fixed: true,
|
||||||
|
visible: true,
|
||||||
|
autoFocus: true,
|
||||||
|
size: 'large',
|
||||||
|
placeholder: '0',
|
||||||
|
unit: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
(e: 'focus'): void
|
||||||
|
(e: 'blur'): void
|
||||||
|
(e: 'confirm'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const innerFocused = ref(false)
|
||||||
|
const cursorIndex = ref(0)
|
||||||
|
|
||||||
|
const innerValue = computed({
|
||||||
|
get: () => props.modelValue || '',
|
||||||
|
set: (value: string) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayChars = computed(() => innerValue.value.split(''))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(value) => {
|
||||||
|
cursorIndex.value = clampCursorIndex(value || '', cursorIndex.value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (visible && props.autoFocus) {
|
||||||
|
nextTick(focus)
|
||||||
|
} else if (!visible) {
|
||||||
|
blur()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
cursorIndex.value = innerValue.value.length
|
||||||
|
if (!innerFocused.value) {
|
||||||
|
innerFocused.value = true
|
||||||
|
emit('focus')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blur() {
|
||||||
|
if (innerFocused.value) {
|
||||||
|
innerFocused.value = false
|
||||||
|
emit('blur')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusAt(index: number) {
|
||||||
|
if (!props.visible) return
|
||||||
|
cursorIndex.value = clampCursorIndex(innerValue.value, index)
|
||||||
|
if (!innerFocused.value) {
|
||||||
|
innerFocused.value = true
|
||||||
|
emit('focus')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusAtEnd() {
|
||||||
|
focusAt(innerValue.value.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focus, blur })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.amount-editor {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 金额显示区 — 未聚焦时低调,聚焦时粘土卡片浮起
|
||||||
|
.amount-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: $space-lg $space-xl $space-md;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
transition: background $transition-normal, border-color $transition-normal, box-shadow $transition-normal;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-editor--focused .amount-display {
|
||||||
|
background: $surface;
|
||||||
|
border-color: $border;
|
||||||
|
box-shadow: $shadow-clay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ¥ 符号
|
||||||
|
.currency {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-muted;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单位(元)
|
||||||
|
.unit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-sec;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 金额字符容器
|
||||||
|
.amount-value {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text;
|
||||||
|
line-height: 1.2;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-char,
|
||||||
|
.placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-char {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #D0C4C4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 光标
|
||||||
|
.cursor {
|
||||||
|
width: 4rpx;
|
||||||
|
height: 1em;
|
||||||
|
margin: 0 2rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: $primary;
|
||||||
|
animation: cursor-blink 1s steps(2, start) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 尺寸变体 =====
|
||||||
|
|
||||||
|
.amount-editor--small {
|
||||||
|
.amount-display {
|
||||||
|
padding: $space-md $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-value {
|
||||||
|
font-size: 52rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
font-size: $font-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-editor--medium {
|
||||||
|
.amount-display {
|
||||||
|
padding: $space-md $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-value {
|
||||||
|
font-size: 68rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency {
|
||||||
|
font-size: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
font-size: $font-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-editor--large {
|
||||||
|
.amount-value {
|
||||||
|
font-size: 88rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency {
|
||||||
|
font-size: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
font-size: $font-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cursor-blink {
|
||||||
|
0%, 45% { opacity: 1; }
|
||||||
|
46%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.amount-display {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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,9 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="numpad">
|
<view class="numpad" :class="{ 'numpad-fixed': fixed, 'numpad-hidden': !visible }">
|
||||||
<view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)">
|
<view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)">
|
||||||
<text class="key-text">{{ key }}</text>
|
<text class="key-text">{{ key }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="key fn" @tap="onDelete">
|
<view
|
||||||
|
class="key fn"
|
||||||
|
@tap="onDeleteTap"
|
||||||
|
@touchstart="onDeleteTouchStart"
|
||||||
|
@touchend="onDeleteTouchEnd"
|
||||||
|
@touchcancel="onDeleteTouchEnd"
|
||||||
|
>
|
||||||
<view class="backspace-icon">
|
<view class="backspace-icon">
|
||||||
<view class="backspace-arrow"></view>
|
<view class="backspace-arrow"></view>
|
||||||
<view class="backspace-line"></view>
|
<view class="backspace-line"></view>
|
||||||
@@ -16,73 +22,118 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onBeforeUnmount } from 'vue'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
import {
|
||||||
|
applyAmountKey,
|
||||||
|
clampCursorIndex,
|
||||||
|
type AmountKey
|
||||||
|
} from '@/components/AmountEditor/amount-edit'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
/** 金额字符串(v-model) */
|
/** 金额字符串(v-model) */
|
||||||
modelValue?: string
|
modelValue?: string
|
||||||
|
/** 光标位置 */
|
||||||
|
cursorIndex?: number
|
||||||
/** 最大金额(元) */
|
/** 最大金额(元) */
|
||||||
max?: number
|
max?: number
|
||||||
|
/** 是否固定在底部(弹窗中使用时设为 false) */
|
||||||
|
fixed?: boolean
|
||||||
|
/** 是否显示(fixed 模式下控制滑入/滑出) */
|
||||||
|
visible?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
modelValue: '',
|
modelValue: '',
|
||||||
max: 9999999.99
|
max: 9999999.99,
|
||||||
|
fixed: true,
|
||||||
|
visible: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: string): void
|
(e: 'update:modelValue', value: string): void
|
||||||
|
(e: 'update:cursorIndex', index: number): void
|
||||||
(e: 'confirm'): void
|
(e: 'confirm'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
|
const keys: AmountKey[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
|
||||||
|
|
||||||
const amountStr = computed(() => props.modelValue)
|
const amountStr = computed(() => props.modelValue)
|
||||||
|
|
||||||
|
const effectiveCursorIndex = computed(() =>
|
||||||
|
clampCursorIndex(amountStr.value, props.cursorIndex ?? amountStr.value.length)
|
||||||
|
)
|
||||||
|
|
||||||
|
let deleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let deleteLongPressed = false
|
||||||
|
|
||||||
function vibrate() {
|
function vibrate() {
|
||||||
try { uni.vibrateShort({ type: 'light' }) } catch {}
|
try { uni.vibrateShort({ type: 'light' }) } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitValue(val: string) {
|
function applyKey(key: AmountKey | 'delete') {
|
||||||
emit('update:modelValue', val)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyTap(key: string) {
|
|
||||||
vibrate()
|
vibrate()
|
||||||
const current = amountStr.value
|
const result = applyAmountKey(
|
||||||
|
amountStr.value,
|
||||||
if (key === '.') {
|
effectiveCursorIndex.value,
|
||||||
if (current.includes('.')) return
|
key,
|
||||||
if (!current || current === '0') { emitValue('0.'); return }
|
{ max: props.max }
|
||||||
|
)
|
||||||
|
if (result.accepted) {
|
||||||
|
emit('update:modelValue', result.value)
|
||||||
|
emit('update:cursorIndex', result.cursorIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let next: string
|
function onKeyTap(key: AmountKey) {
|
||||||
if (!current || current === '0') {
|
applyKey(key)
|
||||||
next = (key === '0' || key === '00') ? '0' : key
|
|
||||||
} else {
|
|
||||||
next = current + key
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.includes('.')) {
|
function onDeleteTap() {
|
||||||
const decimals = next.split('.')[1]
|
if (deleteLongPressed) {
|
||||||
if (decimals && decimals.length > 2) return
|
deleteLongPressed = false
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (next.length > 10) return
|
applyKey('delete')
|
||||||
const num = parseFloat(next)
|
|
||||||
if (!isNaN(num) && num > props.max) return
|
|
||||||
|
|
||||||
emitValue(next)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDelete() {
|
function doDeleteRepeat() {
|
||||||
|
deleteLongPressed = true
|
||||||
|
const result = applyAmountKey(
|
||||||
|
amountStr.value,
|
||||||
|
effectiveCursorIndex.value,
|
||||||
|
'delete',
|
||||||
|
{ max: props.max }
|
||||||
|
)
|
||||||
|
if (result.accepted) {
|
||||||
vibrate()
|
vibrate()
|
||||||
const current = amountStr.value
|
emit('update:modelValue', result.value)
|
||||||
if (current.length > 1) {
|
emit('update:cursorIndex', result.cursorIndex)
|
||||||
emitValue(current.slice(0, -1))
|
deleteTimer = setTimeout(doDeleteRepeat, 120)
|
||||||
} else {
|
} else {
|
||||||
emitValue('')
|
deleteLongPressed = false
|
||||||
|
deleteTimer = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDeleteTouchStart() {
|
||||||
|
if (deleteTimer !== null) clearTimeout(deleteTimer)
|
||||||
|
deleteLongPressed = false
|
||||||
|
deleteTimer = setTimeout(doDeleteRepeat, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteTouchEnd() {
|
||||||
|
if (deleteTimer !== null) {
|
||||||
|
clearTimeout(deleteTimer)
|
||||||
|
deleteTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (deleteTimer !== null) {
|
||||||
|
clearTimeout(deleteTimer)
|
||||||
|
deleteTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function onConfirm() {
|
function onConfirm() {
|
||||||
vibrate()
|
vibrate()
|
||||||
emit('confirm')
|
emit('confirm')
|
||||||
@@ -90,40 +141,91 @@ function onConfirm() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.numpad {
|
.numpad {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
padding: 0 32rpx;
|
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 {
|
.key {
|
||||||
height: 96rpx;
|
height: 96rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: $radius-xl;
|
||||||
background: #FFF8F0;
|
background: $surface;
|
||||||
display: flex;
|
border: 2rpx solid $border;
|
||||||
align-items: center;
|
box-shadow: $shadow-clay;
|
||||||
justify-content: center;
|
@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 {
|
.key-text {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 44rpx;
|
font-size: 44rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.key.fn {
|
.key.fn {
|
||||||
background: #FFE8E0;
|
background: $primary-light;
|
||||||
&:active { background: #FFD0C0; }
|
border-color: #FFD0C0;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #FFD0C0;
|
||||||
|
box-shadow: $shadow-clay-pressed;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.key.eq {
|
.key.eq {
|
||||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
background: linear-gradient(135deg, $primary, $primary-dark);
|
||||||
box-shadow: 0 4rpx 16rpx rgba(255, 140, 105, 0.3);
|
border: none;
|
||||||
&:active { background: #E67355; }
|
box-shadow: $shadow-md, 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: $primary-dark;
|
||||||
|
box-shadow: $shadow-clay-pressed;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自绘 backspace 图标
|
// 自绘 backspace 图标
|
||||||
@@ -139,8 +241,8 @@ function onConfirm() {
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
width: 16rpx;
|
width: 16rpx;
|
||||||
height: 16rpx;
|
height: 16rpx;
|
||||||
border-left: 4rpx solid #FF8C69;
|
border-left: 4rpx solid $primary;
|
||||||
border-bottom: 4rpx solid #FF8C69;
|
border-bottom: 4rpx solid $primary;
|
||||||
transform: translateY(-50%) rotate(45deg);
|
transform: translateY(-50%) rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +252,7 @@ function onConfirm() {
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
width: 26rpx;
|
width: 26rpx;
|
||||||
height: 4rpx;
|
height: 4rpx;
|
||||||
background: #FF8C69;
|
background: $primary;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
border-radius: 2rpx;
|
border-radius: 2rpx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="save-success" v-if="visible" @tap="hide">
|
<view class="save-success" v-if="visible">
|
||||||
<view class="success-icon" :class="{ show: animating }">
|
<view class="success-content" :class="{ show: animating }">
|
||||||
|
<view class="success-icon">
|
||||||
<Icon name="check" :size="80" color="#FFFFFF" />
|
<Icon name="check" :size="80" color="#FFFFFF" />
|
||||||
</view>
|
</view>
|
||||||
<text class="success-text" :class="{ show: animating }">{{ text }}</text>
|
<text class="success-text">{{ text }}</text>
|
||||||
|
<view class="success-actions" v-if="showContinue">
|
||||||
|
<view class="action-btn continue" @tap="onContinue">
|
||||||
|
<text class="action-text">继续记一笔</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-btn back" @tap="onBack">
|
||||||
|
<text class="action-text">返回</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -14,51 +24,61 @@ import Icon from '@/components/Icon/Icon.vue'
|
|||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
text?: string
|
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 animating = ref(false)
|
||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
watch(() => props.visible, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
setTimeout(() => { animating.value = true }, 50)
|
setTimeout(() => { animating.value = true }, 50)
|
||||||
setTimeout(() => { hide() }, 2000)
|
// 如果不显示继续按钮,自动关闭
|
||||||
|
if (!props.showContinue) {
|
||||||
|
setTimeout(() => { onBack() }, 2000)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
animating.value = false
|
animating.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function hide() {
|
function onContinue() {
|
||||||
animating.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.save-success {
|
.save-success {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
display: flex;
|
@include flex-center;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-icon {
|
.success-content {
|
||||||
width: 160rpx;
|
|
||||||
height: 160rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #7BC67E, #5BA85E);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
transform: scale(0.8);
|
||||||
transform: scale(0);
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
||||||
@@ -68,18 +88,47 @@ function hide() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-text {
|
.success-icon {
|
||||||
font-size: 32rpx;
|
width: 160rpx;
|
||||||
font-weight: 600;
|
height: 160rpx;
|
||||||
color: #FFFFFF;
|
border-radius: 50%;
|
||||||
margin-top: 24rpx;
|
background: linear-gradient(135deg, $success, #5BA85E);
|
||||||
transform: translateY(20rpx);
|
@include flex-center;
|
||||||
opacity: 0;
|
|
||||||
transition: all 0.3s ease 0.2s;
|
|
||||||
|
|
||||||
&.show {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
font-size: $font-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $surface;
|
||||||
|
margin-top: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-md;
|
||||||
|
margin-top: $space-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: $space-sm $space-xl;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
min-width: 200rpx;
|
||||||
|
@include flex-center;
|
||||||
|
|
||||||
|
&.continue {
|
||||||
|
background: $primary;
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.back {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
&:active { background: rgba(255, 255, 255, 0.3); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $surface;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ defineEmits(['item-tap', 'delete'])
|
|||||||
|
|
||||||
const creatorAvatarUrl = computed(() => {
|
const creatorAvatarUrl = computed(() => {
|
||||||
if (!props.item.creator_avatar) return ''
|
if (!props.item.creator_avatar) return ''
|
||||||
if (props.item.creator_avatar.startsWith('http')) return props.item.creator_avatar
|
if (props.item.creator_avatar.startsWith('http') || props.item.creator_avatar.startsWith('blob:')) return props.item.creator_avatar
|
||||||
// 截取服务端根地址(去掉 /api)
|
// 截取服务端根地址(去掉 /api)
|
||||||
const base = API_BASE.replace(/\/api$/, '')
|
const base = API_BASE.replace(/\/api$/, '')
|
||||||
return base + '/api/user/avatar/' + props.item.creator_avatar
|
return base + '/api/user/avatar/' + props.item.creator_avatar
|
||||||
|
|||||||
@@ -6,5 +6,23 @@ export function createApp() {
|
|||||||
const app = createSSRApp(App)
|
const app = createSSRApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 全局分享配置,页面可通过定义 onShareAppMessage/onShareTimeline 覆盖
|
||||||
|
app.mixin({
|
||||||
|
onShareAppMessage() {
|
||||||
|
return {
|
||||||
|
title: '小菜记账 — 轻松记账,快乐生活',
|
||||||
|
path: '/pages/index/index'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShareTimeline() {
|
||||||
|
return {
|
||||||
|
title: '小菜记账 — 轻松记账,快乐生活'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
return { app }
|
return { app }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,21 +35,31 @@
|
|||||||
"path": "pages/profile/index",
|
"path": "pages/profile/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "我的"
|
"navigationBarTitleText": "我的",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/category-manage/index",
|
"path": "pages/category-manage/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "分类管理"
|
"navigationBarTitleText": "分类管理",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/recurring/index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "周期账单"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/budget/index",
|
"path": "pages/budget/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "预算设置"
|
"navigationBarTitleText": "预算设置",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,7 +73,8 @@
|
|||||||
"path": "pages/group-manage/index",
|
"path": "pages/group-manage/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "一起记"
|
"navigationBarTitleText": "一起记",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,14 +82,15 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "通知中心",
|
"navigationBarTitleText": "通知中心",
|
||||||
"enablePullDownRefresh": false
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/index",
|
"path": "pages/admin/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "管理后台"
|
"navigationBarTitleText": "管理后台",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,7 +98,61 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
"navigationBarTitleText": "用户管理",
|
"navigationBarTitleText": "用户管理",
|
||||||
"enablePullDownRefresh": false
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/notifications",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "公告管理",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/privacy/index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "隐私政策"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/feedback/index",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "意见反馈"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/feedback",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "反馈管理",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/config",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "系统配置",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/logs",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "系统日志",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/track",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "埋点统计",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,16 +19,26 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="amount-display">
|
<AmountEditor
|
||||||
<text class="currency">¥</text>
|
ref="amountEditorRef"
|
||||||
<text class="digits">{{ displayAmount }}</text>
|
v-model="amountStr"
|
||||||
<text class="cursor">|</text>
|
:fixed="true"
|
||||||
</view>
|
size="large"
|
||||||
|
:auto-focus="true"
|
||||||
|
@focus="keyboardVisible = true"
|
||||||
|
@blur="keyboardVisible = false"
|
||||||
|
@confirm="save"
|
||||||
|
/>
|
||||||
|
|
||||||
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat">
|
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat" @tap="blurAmountEditor">
|
||||||
<view class="category-grid">
|
<view class="category-grid">
|
||||||
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" @tap="selectedCat = cat.id">
|
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id }" @tap="selectedCat = cat.id">
|
||||||
|
<view class="cat-icon-wrap">
|
||||||
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
|
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
|
||||||
|
<view v-if="suggestedCatId === cat.id && selectedCat !== cat.id" class="suggest-badge">
|
||||||
|
<text class="suggest-badge-text">推荐</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text>
|
<text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -36,20 +46,31 @@
|
|||||||
|
|
||||||
<view class="extra-fields">
|
<view class="extra-fields">
|
||||||
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
|
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
|
||||||
<view class="extra-row">
|
<view class="extra-row" @tap="blurAmountEditor">
|
||||||
<Icon name="calendar" :size="28" color="#8B7E7E" />
|
<Icon name="calendar" :size="28" color="#8B7E7E" />
|
||||||
<text class="extra-text">{{ dateLabel }}</text>
|
<text class="extra-text">{{ dateLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
<view class="extra-row">
|
<view class="extra-row">
|
||||||
<Icon name="edit" :size="28" color="#8B7E7E" />
|
<Icon name="edit" :size="28" color="#8B7E7E" />
|
||||||
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="50" />
|
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="blurAmountEditor" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<Numpad v-model="amountStr" @confirm="save" />
|
<!-- 底部保存按钮(键盘收起时显示) -->
|
||||||
|
<view class="save-bar" v-if="!showSuccess && !keyboardVisible">
|
||||||
|
<view class="save-btn" @tap="save">
|
||||||
|
<text class="save-btn-text">{{ editId ? '保存修改' : '保存' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<SaveSuccess v-model:visible="showSuccess" :text="editId ? '修改成功' : '记账成功'" />
|
<SaveSuccess
|
||||||
|
v-model:visible="showSuccess"
|
||||||
|
:text="editId ? '修改成功' : '记账成功'"
|
||||||
|
:showContinue="!editId"
|
||||||
|
@continue="onContinue"
|
||||||
|
@back="onBack"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -59,7 +80,8 @@ import { onShow } from '@dcloudio/uni-app'
|
|||||||
import { useCategoryStore } from '@/stores/category'
|
import { useCategoryStore } from '@/stores/category'
|
||||||
import { useTransactionStore } from '@/stores/transaction'
|
import { useTransactionStore } from '@/stores/transaction'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import Numpad from '@/components/Numpad/Numpad.vue'
|
import { suggestCategory } from '@/api/stats'
|
||||||
|
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
|
||||||
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
|
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
|
||||||
@@ -72,6 +94,9 @@ const type = ref<'expense' | 'income'>('expense')
|
|||||||
const amountStr = ref('0')
|
const amountStr = ref('0')
|
||||||
const selectedCat = ref<number | null>(null)
|
const selectedCat = ref<number | null>(null)
|
||||||
const note = ref('')
|
const note = ref('')
|
||||||
|
const suggestedCatId = ref<number | null>(null)
|
||||||
|
const suggestConfidence = ref(0)
|
||||||
|
let suggestTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||||
const selectedDate = ref(localToday)
|
const selectedDate = ref(localToday)
|
||||||
@@ -80,6 +105,9 @@ const editId = ref<number | null>(null)
|
|||||||
const showSuccess = ref(false)
|
const showSuccess = ref(false)
|
||||||
const scrollToCat = ref('')
|
const scrollToCat = ref('')
|
||||||
const todayStr = localToday
|
const todayStr = localToday
|
||||||
|
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
|
||||||
|
const keyboardVisible = ref(false)
|
||||||
|
|
||||||
// Remember last selected category per type
|
// Remember last selected category per type
|
||||||
const lastCatByType: Record<string, number | null> = { expense: null, income: null }
|
const lastCatByType: Record<string, number | null> = { expense: null, income: null }
|
||||||
|
|
||||||
@@ -88,10 +116,6 @@ const dateLabel = computed(() => {
|
|||||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayAmount = computed(() => {
|
|
||||||
if (!amountStr.value) return ''
|
|
||||||
return amountStr.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentCategories = computed(() => catStore.getByType(type.value))
|
const currentCategories = computed(() => catStore.getByType(type.value))
|
||||||
|
|
||||||
@@ -115,11 +139,51 @@ watch(currentCategories, (cats) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 智能分类推荐:监听金额和备注变化,防抖 600ms 后请求建议
|
||||||
|
watch([amountStr, note], () => {
|
||||||
|
if (suggestTimer) clearTimeout(suggestTimer)
|
||||||
|
suggestTimer = setTimeout(async () => {
|
||||||
|
const amt = Math.round(parseFloat(amountStr.value || '0') * 100)
|
||||||
|
if (amt <= 0 || editId.value) {
|
||||||
|
suggestedCatId.value = null
|
||||||
|
suggestConfidence.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await suggestCategory(amt, type.value, note.value)
|
||||||
|
if (result.category && result.confidence >= 30) {
|
||||||
|
suggestedCatId.value = result.category.id
|
||||||
|
suggestConfidence.value = result.confidence
|
||||||
|
// 置信度 >= 60 时自动选中
|
||||||
|
if (result.confidence >= 60 && !editId.value) {
|
||||||
|
selectedCat.value = result.category.id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
suggestedCatId.value = null
|
||||||
|
suggestConfidence.value = 0
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
suggestedCatId.value = null
|
||||||
|
}
|
||||||
|
}, 600)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
|
|
||||||
|
// 加载分类列表,失败时重试一次
|
||||||
|
try {
|
||||||
await catStore.fetchCategories()
|
await catStore.fetchCategories()
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await catStore.fetchCategories()
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '分类加载失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
const page = pages[pages.length - 1] as any
|
const page = pages[pages.length - 1] as { options?: Record<string, string> }
|
||||||
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {
|
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {
|
||||||
type.value = page.options.type
|
type.value = page.options.type
|
||||||
}
|
}
|
||||||
@@ -140,6 +204,7 @@ onMounted(async () => {
|
|||||||
// 等待分类列表渲染完成后滚动到选中项
|
// 等待分类列表渲染完成后滚动到选中项
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
|
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
|
||||||
|
amountEditorRef.value?.focus()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
uni.showToast({ title: '记录不存在', icon: 'none' })
|
uni.showToast({ title: '记录不存在', icon: 'none' })
|
||||||
@@ -162,6 +227,10 @@ function onDateChange(e: any) {
|
|||||||
selectedDate.value = e.detail.value
|
selectedDate.value = e.detail.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blurAmountEditor() {
|
||||||
|
amountEditorRef.value?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (saving.value) return
|
if (saving.value) return
|
||||||
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
|
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
|
||||||
@@ -189,7 +258,10 @@ async function save() {
|
|||||||
await txStore.addTransaction(data)
|
await txStore.addTransaction(data)
|
||||||
}
|
}
|
||||||
showSuccess.value = true
|
showSuccess.value = true
|
||||||
setTimeout(() => uni.navigateBack(), 2500)
|
// 编辑模式自动返回,新增模式显示继续按钮
|
||||||
|
if (editId.value) {
|
||||||
|
setTimeout(() => uni.navigateBack(), 2000)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -197,6 +269,20 @@ async function save() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 继续记一笔 */
|
||||||
|
function onContinue() {
|
||||||
|
// 重置表单,保留分类和日期
|
||||||
|
amountStr.value = ''
|
||||||
|
note.value = ''
|
||||||
|
showSuccess.value = false
|
||||||
|
nextTick(() => amountEditorRef.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回上一页 */
|
||||||
|
function onBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
// 保存成功后直接返回,不弹确认框
|
// 保存成功后直接返回,不弹确认框
|
||||||
if (showSuccess.value) return
|
if (showSuccess.value) return
|
||||||
@@ -216,51 +302,85 @@ function goBack() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #FFF8F0;
|
background: $bg;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
// AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
|
||||||
|
// 同时留出保存按钮的位置,确保内容不被遮挡
|
||||||
|
$numpad-h: 464rpx;
|
||||||
|
$save-bar-h: 144rpx;
|
||||||
|
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + constant(safe-area-inset-bottom));
|
||||||
|
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
// 底部保存按钮栏
|
||||||
position: sticky;
|
.save-bar {
|
||||||
top: 0;
|
position: fixed;
|
||||||
z-index: 100;
|
bottom: 0;
|
||||||
background: #FFF8F0;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: $space-md $space-xl;
|
||||||
|
padding-bottom: calc(#{$space-md} + constant(safe-area-inset-bottom));
|
||||||
|
padding-bottom: calc(#{$space-md} + env(safe-area-inset-bottom));
|
||||||
|
background: $surface;
|
||||||
|
border-top: 2rpx solid $border;
|
||||||
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar {
|
.save-btn {
|
||||||
background: #FFF8F0;
|
height: 96rpx;
|
||||||
}
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
|
border-radius: $radius-xl;
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 16rpx 40rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-back {
|
|
||||||
width: 88rpx;
|
|
||||||
height: 88rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
|
||||||
|
|
||||||
&:active { background: #F0E0D6; }
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title { font-size: 32rpx; font-weight: 500; color: #2D1B1B; }
|
.save-btn-text {
|
||||||
|
font-size: $font-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
.type-toggle-wrap { display: flex; justify-content: center; margin: 16rpx 0; }
|
.header-fixed {
|
||||||
|
@include sticky-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
@include status-bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
@include nav-bar;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
@include nav-back;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&:active { background: $border; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.title { font-size: $font-xl; font-weight: 500; color: $text; }
|
||||||
|
|
||||||
|
.type-toggle-wrap { display: flex; justify-content: center; margin: $space-sm 0; }
|
||||||
|
|
||||||
.type-toggle {
|
.type-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
background: #FFF0E6;
|
background: $surface-warm;
|
||||||
border-radius: 24rpx;
|
border-radius: $radius-lg;
|
||||||
padding: 8rpx;
|
padding: $space-xs;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,62 +390,30 @@ function goBack() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
transition: color 0.2s;
|
transition: color $transition-normal;
|
||||||
|
|
||||||
&.active { color: #FF8C69; font-weight: 600; }
|
&.active { color: $primary; font-weight: 600; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider {
|
.slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8rpx;
|
top: $space-xs;
|
||||||
left: 8rpx;
|
left: $space-xs;
|
||||||
width: 160rpx;
|
width: 160rpx;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-sm;
|
||||||
transition: transform 0.2s;
|
transition: transform $transition-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.income .slider { transform: translateX(160rpx); }
|
.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 {
|
.category-scroll {
|
||||||
max-height: 320rpx;
|
max-height: 320rpx;
|
||||||
@@ -334,8 +422,8 @@ function goBack() {
|
|||||||
.category-grid {
|
.category-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
padding: 8rpx 0;
|
padding: $space-xs 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-item {
|
.cat-item {
|
||||||
@@ -344,19 +432,49 @@ function goBack() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6rpx;
|
gap: 6rpx;
|
||||||
padding: 12rpx 0;
|
padding: 12rpx 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:active { transform: scale(0.95); }
|
&:active { transform: scale(0.95); }
|
||||||
|
|
||||||
|
&.suggested {
|
||||||
|
.cat-icon-wrap {
|
||||||
|
border: 3rpx dashed $primary;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: 6rpx;
|
||||||
|
margin: -6rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-name { font-size: 22rpx; color: #8B7E7E; }
|
.cat-icon-wrap {
|
||||||
|
position: relative;
|
||||||
|
transition: all $transition-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggest-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -8rpx;
|
||||||
|
right: -12rpx;
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
background: $primary;
|
||||||
|
border-radius: $radius-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggest-badge-text {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: $surface;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-name { font-size: $font-sm; color: $text-sec; }
|
||||||
|
|
||||||
.cat-name.active {
|
.cat-name.active {
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.extra-fields {
|
.extra-fields {
|
||||||
padding: 16rpx 40rpx;
|
padding: $space-sm $space-xl;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12rpx;
|
gap: 12rpx;
|
||||||
@@ -364,24 +482,20 @@ function goBack() {
|
|||||||
|
|
||||||
.extra-row {
|
.extra-row {
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
padding: 0 24rpx;
|
padding: 0 $space-md;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12rpx;
|
gap: 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.extra-text { font-size: 28rpx; color: #2D1B1B; flex: 1; }
|
.extra-text { font-size: $font-lg; color: $text; flex: 1; }
|
||||||
.extra-input { font-size: 28rpx; color: #2D1B1B; flex: 1; }
|
.extra-input { font-size: $font-lg; color: $text; flex: 1; }
|
||||||
|
|
||||||
|
.placeholder { color: $text-muted; }
|
||||||
|
|
||||||
|
|
||||||
.placeholder { color: #BFB3B3; }
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.cursor {
|
|
||||||
animation: none;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</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 () => {
|
||||||
|
await waitForReady()
|
||||||
|
try {
|
||||||
|
await loadConfig()
|
||||||
|
} catch (e: any) {
|
||||||
|
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
initialLoaded.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载配置数据
|
||||||
|
async function loadConfig() {
|
||||||
|
const config = await getConfig()
|
||||||
|
form.app_version = config.app_version || '1.0.0'
|
||||||
|
form.app_slogan = config.app_slogan || ''
|
||||||
|
form.app_description = config.app_description || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回页面时静默刷新
|
||||||
|
onShow(() => {
|
||||||
|
if (initialLoaded.value) loadConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await loadConfig()
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
if (saving.value) return
|
||||||
|
|
||||||
|
if (!form.app_version.trim()) {
|
||||||
|
uni.showToast({ title: '请输入版本号', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await updateConfig({
|
||||||
|
app_version: form.app_version.trim(),
|
||||||
|
app_slogan: form.app_slogan.trim(),
|
||||||
|
app_description: form.app_description.trim()
|
||||||
|
})
|
||||||
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 1500)
|
||||||
|
} catch (e: any) {
|
||||||
|
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fixed {
|
||||||
|
@include sticky-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm $space-xl $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back { @include nav-back; }
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-save {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: $space-lg $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card {
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-2xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
padding: $space-lg $space-xl $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-lg $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label {
|
||||||
|
width: 120rpx;
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-divider {
|
||||||
|
height: 2rpx;
|
||||||
|
background: $border;
|
||||||
|
margin: 0 $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-card {
|
||||||
|
padding: $space-lg $space-xl;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-2xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
615
client/src/pages/admin/feedback.vue
Normal file
615
client/src/pages/admin/feedback.vue
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="header-fixed">
|
||||||
|
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||||
|
<view class="nav-bar">
|
||||||
|
<view class="nav-back" @tap="goBack">
|
||||||
|
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
|
||||||
|
</view>
|
||||||
|
<text class="nav-title">反馈管理</text>
|
||||||
|
<view class="nav-placeholder"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 状态筛选 -->
|
||||||
|
<view class="tabs-wrap">
|
||||||
|
<view class="tabs">
|
||||||
|
<view class="tab" :class="{ active: filterStatus === '' }" @tap="switchStatus('')">全部</view>
|
||||||
|
<view class="tab" :class="{ active: filterStatus === 'pending' }" @tap="switchStatus('pending')">待处理</view>
|
||||||
|
<view class="tab" :class="{ active: filterStatus === 'processed' }" @tap="switchStatus('processed')">已处理</view>
|
||||||
|
<view class="tab" :class="{ active: filterStatus === 'ignored' }" @tap="switchStatus('ignored')">已忽略</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<scroll-view class="list" scroll-y @scrolltolower="loadMore">
|
||||||
|
<view v-for="item in list" :key="item.id" class="feedback-card">
|
||||||
|
<view class="card-header">
|
||||||
|
<view class="user-info">
|
||||||
|
<image v-if="item.avatar_url" class="avatar" :src="getAvatarUrl(item.avatar_url)" mode="aspectFill" />
|
||||||
|
<view v-else class="avatar-placeholder">
|
||||||
|
<Icon name="user" :size="24" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
|
<text class="nickname">{{ item.nickname || '用户' + item.user_id }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="status-badge" :class="item.status">
|
||||||
|
<text class="status-text">{{ statusMap[item.status] }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="type-tag">
|
||||||
|
<text class="type-text">{{ typeMap[item.type] }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="content">{{ item.content }}</text>
|
||||||
|
|
||||||
|
<view v-if="item.contact" class="contact-row">
|
||||||
|
<text class="contact-label">联系方式:</text>
|
||||||
|
<text class="contact-value" @tap="copyContact(item.contact)">{{ item.contact }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="time">{{ formatTime(item.created_at) }}</text>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<view class="actions">
|
||||||
|
<view v-if="item.status === 'pending'" class="action-btn process" @tap.stop="openReplyModal(item)">
|
||||||
|
<text class="action-text">标记处理</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.status === 'pending'" class="action-btn ignore" @tap.stop="openReplyModal(item, 'ignored')">
|
||||||
|
<text class="action-text">忽略</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="item.status !== 'pending'" class="action-btn pending" @tap.stop="updateStatus(item, 'pending')">
|
||||||
|
<text class="action-text">重新打开</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading" class="loading-box">
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="noMore && list.length > 0" class="no-more">
|
||||||
|
<text class="no-more-text">没有更多了</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!loading && list.length === 0" class="empty-box">
|
||||||
|
<Icon name="edit" :size="48" color="#BFB3B3" />
|
||||||
|
<text class="empty-text">暂无反馈</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 回复弹窗 -->
|
||||||
|
<view class="modal-mask" v-if="showReplyModal" @tap="showReplyModal = false">
|
||||||
|
<view class="reply-modal" @tap.stop>
|
||||||
|
<view class="modal-header">
|
||||||
|
<text class="modal-title">{{ replyAction === 'processed' ? '标记处理' : '忽略反馈' }}</text>
|
||||||
|
<view class="modal-close" @tap="showReplyModal = false">
|
||||||
|
<text class="modal-close-text">×</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="reply-body">
|
||||||
|
<view class="reply-origin" v-if="replyTarget">
|
||||||
|
<text class="reply-origin-label">用户反馈:</text>
|
||||||
|
<text class="reply-origin-text">{{ replyTarget.content }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="reply-label">回复内容(可选)</text>
|
||||||
|
<textarea
|
||||||
|
class="reply-textarea"
|
||||||
|
v-model="replyText"
|
||||||
|
placeholder="输入回复内容,将通知给用户..."
|
||||||
|
maxlength="500"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="reply-footer">
|
||||||
|
<view class="reply-btn skip" @tap="handleSkipReply">
|
||||||
|
<text class="reply-btn-text">{{ replyAction === 'processed' ? '仅标记处理' : '仅忽略' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="reply-btn confirm" @tap="handleWithReply">
|
||||||
|
<text class="reply-btn-text">{{ replyAction === 'processed' ? '回复并处理' : '回复并忽略' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
|
import { statusBarHeight } from '@/utils/system'
|
||||||
|
import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback'
|
||||||
|
import type { Feedback } from '@/api/feedback'
|
||||||
|
import { API_BASE } from '@/config'
|
||||||
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
bug: '功能异常',
|
||||||
|
feature: '功能建议',
|
||||||
|
ux: '体验问题',
|
||||||
|
other: '其他'
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
pending: '待处理',
|
||||||
|
processed: '已处理',
|
||||||
|
ignored: '已忽略'
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = ref<Feedback[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const filterStatus = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const total = ref(0)
|
||||||
|
const initialLoaded = ref(false)
|
||||||
|
|
||||||
|
// 回复弹窗
|
||||||
|
const showReplyModal = ref(false)
|
||||||
|
const replyTarget = ref<Feedback | null>(null)
|
||||||
|
const replyAction = ref<'processed' | 'ignored'>('processed')
|
||||||
|
const replyText = ref('')
|
||||||
|
|
||||||
|
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await waitForReady()
|
||||||
|
loadList(true).finally(() => { initialLoaded.value = true })
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (initialLoaded.value) loadList(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await loadList(true)
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
function switchStatus(status: string) {
|
||||||
|
filterStatus.value = status
|
||||||
|
loadList(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadList(reset = false) {
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1
|
||||||
|
list.value = []
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: { page: number; pageSize: number; status?: string } = { page: page.value, pageSize }
|
||||||
|
if (filterStatus.value) params.status = filterStatus.value
|
||||||
|
const data = await getFeedbackList(params)
|
||||||
|
if (reset) {
|
||||||
|
list.value = data.list
|
||||||
|
} else {
|
||||||
|
list.value = [...list.value, ...data.list]
|
||||||
|
}
|
||||||
|
total.value = data.total
|
||||||
|
} catch (e: any) {
|
||||||
|
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
if (noMore.value || loading.value) return
|
||||||
|
page.value++
|
||||||
|
loadList(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReplyModal(item: Feedback, action: 'processed' | 'ignored' = 'processed') {
|
||||||
|
replyTarget.value = item
|
||||||
|
replyAction.value = action
|
||||||
|
replyText.value = ''
|
||||||
|
showReplyModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSkipReply() {
|
||||||
|
if (!replyTarget.value) return
|
||||||
|
await doUpdateStatus(replyTarget.value, replyAction.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWithReply() {
|
||||||
|
if (!replyTarget.value) return
|
||||||
|
await doUpdateStatus(replyTarget.value, replyAction.value, replyText.value.trim() || undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUpdateStatus(item: Feedback, status: Feedback['status'], admin_reply?: string) {
|
||||||
|
try {
|
||||||
|
await updateFeedbackStatus(item.id, status, admin_reply)
|
||||||
|
item.status = status
|
||||||
|
showReplyModal.value = false
|
||||||
|
uni.showToast({ title: admin_reply ? '已回复' : '已更新', icon: 'success' })
|
||||||
|
} catch (e: any) {
|
||||||
|
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(item: Feedback, status: Feedback['status']) {
|
||||||
|
try {
|
||||||
|
await updateFeedbackStatus(item.id, status)
|
||||||
|
item.status = status
|
||||||
|
uni.showToast({ title: '已更新', icon: 'success' })
|
||||||
|
} catch (e: any) {
|
||||||
|
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyContact(contact: string) {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: contact,
|
||||||
|
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvatarUrl(avatarUrl: string): string {
|
||||||
|
if (!avatarUrl) return ''
|
||||||
|
if (avatarUrl.startsWith('http') || avatarUrl.startsWith('blob:')) return avatarUrl
|
||||||
|
return `${API_BASE}/user/avatar/${avatarUrl}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
const hours = Math.floor(diff / 3600000)
|
||||||
|
const days = Math.floor(diff / 86400000)
|
||||||
|
|
||||||
|
if (minutes < 1) return '刚刚'
|
||||||
|
if (minutes < 60) return `${minutes}分钟前`
|
||||||
|
if (hours < 24) return `${hours}小时前`
|
||||||
|
if (days < 7) return `${days}天前`
|
||||||
|
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() { uni.navigateBack() }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fixed {
|
||||||
|
@include sticky-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm $space-xl $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back { @include nav-back; }
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder { width: 88rpx; }
|
||||||
|
|
||||||
|
.tabs-wrap {
|
||||||
|
@include tabs-wrap;
|
||||||
|
margin: 0 0 $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
@include tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
@include tab;
|
||||||
|
font-size: $font-md;
|
||||||
|
padding: $space-sm $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
height: calc(100vh - 300rpx);
|
||||||
|
padding: 0 $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-card {
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
padding: $space-lg;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $border;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: $radius-xs;
|
||||||
|
|
||||||
|
&.pending { background: #FFF3E0; }
|
||||||
|
&.processed { background: #E8F5E9; }
|
||||||
|
&.ignored { background: #F5F5F5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: $font-xs;
|
||||||
|
|
||||||
|
.pending & { color: #F57C00; }
|
||||||
|
.processed & { color: #43A047; }
|
||||||
|
.ignored & { color: #9E9E9E; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 2rpx 12rpx;
|
||||||
|
background: $primary-light;
|
||||||
|
border-radius: $radius-xs;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-text {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-label {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-value {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $primary;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-muted;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-sm;
|
||||||
|
padding-top: $space-md;
|
||||||
|
border-top: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: $space-xs $space-lg;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
|
||||||
|
&.process {
|
||||||
|
background: $primary-light;
|
||||||
|
border-color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ignore {
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.pending {
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
|
||||||
|
.process & { color: $primary; font-weight: 500; }
|
||||||
|
.ignore & { color: $text-sec; }
|
||||||
|
.pending & { color: $text-sec; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-box, .empty-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
padding: 80rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text, .empty-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: $space-lg 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 回复弹窗 */
|
||||||
|
.reply-modal {
|
||||||
|
width: 100%;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||||
|
padding-bottom: calc($space-xl + env(safe-area-inset-bottom));
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: $space-lg $space-xl $space-md;
|
||||||
|
border-bottom: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: $font-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $border;
|
||||||
|
&:active { background: darken($border, 5%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-text {
|
||||||
|
font-size: $font-2xl;
|
||||||
|
color: $text-sec;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-body {
|
||||||
|
padding: $space-lg $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-origin {
|
||||||
|
padding: $space-md;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
margin-bottom: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-origin-label {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-muted;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-origin-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-label {
|
||||||
|
font-size: $font-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 200rpx;
|
||||||
|
padding: $space-md;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-md;
|
||||||
|
padding: $space-md $space-xl;
|
||||||
|
border-top: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.skip {
|
||||||
|
background: $bg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.confirm {
|
||||||
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-btn-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.skip & { color: $text-sec; }
|
||||||
|
.confirm & { color: $surface; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
@include modal-mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -42,16 +42,20 @@
|
|||||||
<view class="detail-card">
|
<view class="detail-card">
|
||||||
<text class="detail-title">本月概况</text>
|
<text class="detail-title">本月概况</text>
|
||||||
<view class="detail-row">
|
<view class="detail-row">
|
||||||
<text class="detail-label">交易总额</text>
|
<text class="detail-label">支出总额</text>
|
||||||
<text class="detail-value">{{ formatAmount(dashboard.monthlyTxAmount) }}</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>
|
||||||
<view class="detail-row">
|
<view class="detail-row">
|
||||||
<text class="detail-label">月活用户</text>
|
<text class="detail-label">月活用户</text>
|
||||||
<text class="detail-value">{{ dashboard.monthlyActive }}</text>
|
<text class="detail-value">{{ dashboard.monthlyActive }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="detail-row">
|
<view class="detail-row">
|
||||||
<text class="detail-label">上月交易</text>
|
<text class="detail-label">上月支出</text>
|
||||||
<text class="detail-value">{{ dashboard.lastMonthTxCount }} 笔 / {{ formatAmount(dashboard.lastMonthTxAmount) }}</text>
|
<text class="detail-value">{{ formatAmount(dashboard.lastMonthExpense || 0) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -63,78 +67,30 @@
|
|||||||
<text class="entry-text">用户管理</text>
|
<text class="entry-text">用户管理</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
</view>
|
</view>
|
||||||
<view class="entry-item" @tap="goNotifications">
|
<view class="entry-item" @tap="goNotificationManage">
|
||||||
<Icon name="bell" :size="32" color="#FF8C69" />
|
<Icon name="bell" :size="32" color="#FF8C69" />
|
||||||
<text class="entry-text">发布公告</text>
|
<text class="entry-text">公告管理</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
</view>
|
</view>
|
||||||
|
<view class="entry-item" @tap="goFeedbackManage">
|
||||||
|
<Icon name="edit" :size="32" color="#FF8C69" />
|
||||||
|
<text class="entry-text">反馈管理</text>
|
||||||
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
</view>
|
</view>
|
||||||
|
<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>
|
||||||
|
<view class="entry-item" @tap="goLogs">
|
||||||
<!-- 发布公告弹窗 -->
|
<Icon name="edit" :size="32" color="#FF8C69" />
|
||||||
<view class="modal-mask" v-if="showPublishModal" @tap="showPublishModal = false">
|
<text class="entry-text">系统日志</text>
|
||||||
<view class="publish-modal" @tap.stop>
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
<view class="modal-header">
|
|
||||||
<text class="modal-title">发布公告</text>
|
|
||||||
<view class="modal-close" @tap="showPublishModal = false">
|
|
||||||
<text class="modal-close-text">×</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
<view class="entry-item" @tap="goTrack">
|
||||||
<scroll-view class="modal-body" scroll-y>
|
<Icon name="edit" :size="32" color="#FF8C69" />
|
||||||
<view class="form-group">
|
<text class="entry-text">埋点统计</text>
|
||||||
<text class="form-label">标题 *</text>
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -143,10 +99,10 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight } from '@/utils/system'
|
||||||
import { getDashboard } from '@/api/admin'
|
import { getDashboard } from '@/api/admin'
|
||||||
import { publishNotification } from '@/api/notification'
|
|
||||||
import { formatAmount } from '@/utils/format'
|
import { formatAmount } from '@/utils/format'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import type { Dashboard } from '@/api/admin'
|
import type { Dashboard } from '@/api/admin'
|
||||||
@@ -156,6 +112,7 @@ const dashboard = ref<Dashboard | null>(null)
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
dashboard.value = await getDashboard()
|
dashboard.value = await getDashboard()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -165,97 +122,61 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 静默刷新
|
||||||
|
async function refreshDashboard() {
|
||||||
|
try {
|
||||||
|
dashboard.value = await getDashboard()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回页面时静默刷新
|
||||||
|
onShow(() => {
|
||||||
|
refreshDashboard()
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await refreshDashboard()
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
function goBack() { uni.navigateBack() }
|
function goBack() { uni.navigateBack() }
|
||||||
function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) }
|
function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) }
|
||||||
const showPublishModal = ref(false)
|
function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) }
|
||||||
const publishForm = ref({
|
function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) }
|
||||||
title: '',
|
function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
|
||||||
content: '',
|
function goLogs() { uni.navigateTo({ url: '/pages/admin/logs' }) }
|
||||||
is_pinned: false,
|
function goTrack() { uni.navigateTo({ url: '/pages/admin/track' }) }
|
||||||
is_urgent: false,
|
|
||||||
link_url: '',
|
|
||||||
image_url: '',
|
|
||||||
publish_at: '',
|
|
||||||
expire_at: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
function goNotifications() {
|
|
||||||
publishForm.value = { title: '', content: '', is_pinned: false, is_urgent: false, link_url: '', image_url: '', publish_at: '', expire_at: '' }
|
|
||||||
showPublishModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 选择配图 */
|
|
||||||
function chooseImage() {
|
|
||||||
uni.chooseImage({
|
|
||||||
count: 1,
|
|
||||||
sizeType: ['compressed'],
|
|
||||||
success: (res) => {
|
|
||||||
publishForm.value.image_url = res.tempFilePaths[0]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handlePublish() {
|
|
||||||
if (!publishForm.value.title.trim()) {
|
|
||||||
uni.showToast({ title: '请输入标题', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await publishNotification({
|
|
||||||
title: publishForm.value.title.trim(),
|
|
||||||
content: publishForm.value.content.trim(),
|
|
||||||
is_pinned: publishForm.value.is_pinned,
|
|
||||||
is_urgent: publishForm.value.is_urgent,
|
|
||||||
link_url: publishForm.value.link_url.trim() || undefined,
|
|
||||||
image_url: publishForm.value.image_url || undefined,
|
|
||||||
publish_at: publishForm.value.publish_at || undefined,
|
|
||||||
expire_at: publishForm.value.expire_at || undefined
|
|
||||||
})
|
|
||||||
showPublishModal.value = false
|
|
||||||
uni.showToast({ title: '已发布', icon: 'success' })
|
|
||||||
} catch (e: any) {
|
|
||||||
uni.showToast({ title: e.message || '发布失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
@include page-base;
|
||||||
background: #FFF8F0;
|
|
||||||
padding: 0 0 180rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
position: sticky;
|
@include sticky-header;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar { background: #FFF8F0; }
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
.nav-bar {
|
.nav-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 40rpx 24rpx;
|
padding: $space-sm $space-xl $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-back {
|
.nav-back {
|
||||||
width: 88rpx;
|
@include nav-back;
|
||||||
height: 88rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
&:active { opacity: 0.6; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-title {
|
.nav-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 36rpx;
|
font-size: $font-2xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-placeholder { width: 88rpx; }
|
.nav-placeholder { width: 88rpx; }
|
||||||
@@ -268,60 +189,60 @@ async function handlePublish() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.loading-text {
|
.loading-text {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content { padding: 0 40rpx; }
|
.content { padding: 0 $space-xl; }
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card {
|
.metric-card {
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 28rpx;
|
border-radius: $radius-xl;
|
||||||
padding: 28rpx 24rpx;
|
padding: $font-lg $space-md;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-value {
|
.metric-value {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 40rpx;
|
font-size: $font-3xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
&.up { color: #7BC67E; }
|
&.up { color: $success; }
|
||||||
&.down { color: #FF6B6B; }
|
&.down { color: $danger; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-label {
|
.metric-label {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 8rpx;
|
margin-top: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-card {
|
.detail-card {
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 28rpx;
|
border-radius: $radius-xl;
|
||||||
padding: 32rpx;
|
padding: $space-lg;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 20rpx;
|
margin-bottom: $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-row {
|
.detail-row {
|
||||||
@@ -335,262 +256,47 @@ async function handlePublish() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-label {
|
.detail-label {
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-value {
|
.detail-value {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
|
|
||||||
|
&.expense { color: $danger; }
|
||||||
|
&.income { color: $success; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-list { margin-bottom: 32rpx; }
|
.entry-list { margin-bottom: $space-lg; }
|
||||||
|
|
||||||
.entry-item {
|
.entry-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20rpx;
|
gap: $space-lg;
|
||||||
padding: 28rpx 32rpx;
|
padding: $font-lg $space-lg;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 28rpx;
|
border-radius: $radius-xl;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: $space-sm;
|
||||||
|
|
||||||
&:active { background: #FFF8F0; }
|
&:active { background: $bg; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-text {
|
.entry-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 500;
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.publish-modal {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 85vh;
|
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 40rpx 40rpx 0 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 32rpx 40rpx 24rpx;
|
|
||||||
border-bottom: 2rpx solid #F0E0D6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2D1B1B;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
width: 56rpx;
|
|
||||||
height: 56rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #F0E0D6;
|
|
||||||
&:active { background: #E0D6D0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close-text {
|
|
||||||
font-size: 36rpx;
|
|
||||||
color: #8B7E7E;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
flex: 1;
|
|
||||||
padding: 24rpx 40rpx;
|
|
||||||
max-height: 60vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
font-size: 26rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2D1B1B;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
width: 100%;
|
|
||||||
height: 80rpx;
|
|
||||||
padding: 0 24rpx;
|
|
||||||
background: #FFF8F0;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
border: 2rpx solid #F0E0D6;
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #2D1B1B;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-textarea {
|
|
||||||
width: 100%;
|
|
||||||
height: 200rpx;
|
|
||||||
padding: 16rpx 24rpx;
|
|
||||||
background: #FFF8F0;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
border: 2rpx solid #F0E0D6;
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #2D1B1B;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch-item {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16rpx 20rpx;
|
|
||||||
background: #FFF8F0;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
border: 2rpx solid #F0E0D6;
|
|
||||||
|
|
||||||
&:active { background: #F0E0D6; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch-label {
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #2D1B1B;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch-toggle {
|
|
||||||
width: 44rpx;
|
|
||||||
height: 24rpx;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
background: #D0C4C4;
|
|
||||||
position: relative;
|
|
||||||
transition: background 0.2s;
|
|
||||||
|
|
||||||
&.on { background: #FF8C69; }
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 2rpx;
|
|
||||||
left: 2rpx;
|
|
||||||
width: 20rpx;
|
|
||||||
height: 20rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #FFFFFF;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.on::after { transform: translateX(20rpx); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding: 24rpx 40rpx 40rpx;
|
|
||||||
border-top: 2rpx solid #F0E0D6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.publish-btn-text {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #FFFFFF;
|
|
||||||
padding: 20rpx;
|
|
||||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
|
||||||
border-radius: 20rpx;
|
|
||||||
|
|
||||||
&:active { opacity: 0.8; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-upload {
|
|
||||||
width: 200rpx;
|
|
||||||
height: 200rpx;
|
|
||||||
border: 2rpx dashed #D0C4C4;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
|
|
||||||
&:active { background: #FFF8F0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-text {
|
|
||||||
font-size: 24rpx;
|
|
||||||
color: #BFB3B3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview {
|
|
||||||
position: relative;
|
|
||||||
width: 200rpx;
|
|
||||||
height: 200rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-remove {
|
|
||||||
position: absolute;
|
|
||||||
top: -12rpx;
|
|
||||||
right: -12rpx;
|
|
||||||
width: 40rpx;
|
|
||||||
height: 40rpx;
|
|
||||||
background: #FF6B6B;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-remove-text {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #FFFFFF;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-input {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.placeholder { color: #BFB3B3; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</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 () => {
|
||||||
|
await waitForReady()
|
||||||
|
await Promise.all([loadStats(), loadLogs()])
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadStats()
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await Promise.all([loadStats(), loadLogs()])
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
stats.value = await getLogStats()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs(append = false) {
|
||||||
|
if (loading.value) return
|
||||||
|
if (!append) {
|
||||||
|
page.value = 1
|
||||||
|
logLines.value = []
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await queryLogs({
|
||||||
|
date: selectedDate.value,
|
||||||
|
level: filterLevel.value === 'all' ? undefined : filterLevel.value,
|
||||||
|
keyword: keyword.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: 100
|
||||||
|
})
|
||||||
|
if (append) {
|
||||||
|
logLines.value = [...logLines.value, ...data.lines]
|
||||||
|
} else {
|
||||||
|
logLines.value = data.lines
|
||||||
|
}
|
||||||
|
total.value = data.total
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
if (noMore.value || loading.value) return
|
||||||
|
page.value++
|
||||||
|
loadLogs(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDate(date: string) {
|
||||||
|
selectedDate.value = date
|
||||||
|
// loadLogs() 由 watch([filterLevel, selectedDate]) 统一触发,避免双重请求
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateChange(e: any) {
|
||||||
|
selectedDate.value = e.detail.value
|
||||||
|
// loadLogs() 由 watch 统一触发
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBarWidth(count: number): number {
|
||||||
|
if (!stats.value) return 0
|
||||||
|
const max = Math.max(...stats.value.daily.map(d => d.total), 1)
|
||||||
|
return (count / max) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineClass(line: string): string {
|
||||||
|
if (line.includes(' ERROR ')) return 'error'
|
||||||
|
if (line.includes(' WARN ')) return 'warn'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选条件变化时重新加载
|
||||||
|
watch([filterLevel, selectedDate], () => {
|
||||||
|
page.value = 1
|
||||||
|
loadLogs()
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fixed {
|
||||||
|
@include sticky-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm $space-xl $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back { @include nav-back; }
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder { width: 88rpx; }
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
margin: $space-md $space-xl;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
padding: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: $space-sm;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
background: $bg;
|
||||||
|
|
||||||
|
&.error { background: #FFF0F0; }
|
||||||
|
&.warn { background: #FFF8E1; }
|
||||||
|
&.slow { background: #FFF3E0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.error & { color: $danger; }
|
||||||
|
.warn & { color: #F57C00; }
|
||||||
|
.slow & { color: #FF9800; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-sec;
|
||||||
|
display: block;
|
||||||
|
margin-top: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
padding: $space-xs 0;
|
||||||
|
|
||||||
|
&:active { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-date {
|
||||||
|
width: 80rpx;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 16rpx;
|
||||||
|
background: $border;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, $primary, #FFB89A);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-count {
|
||||||
|
width: 80rpx;
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-errors {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $danger;
|
||||||
|
width: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-section {
|
||||||
|
margin: $space-md $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-xs;
|
||||||
|
padding: $space-xs $space-md;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
|
||||||
|
&:active { background: $bg; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-md;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
padding: 0 $space-md;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
font-size: $font-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
height: 64rpx;
|
||||||
|
padding: 0 $space-md;
|
||||||
|
background: $primary;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $surface;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-xs;
|
||||||
|
background: $surface-warm;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
padding: $space-xs $space-md;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $surface;
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-list {
|
||||||
|
height: calc(100vh - 700rpx);
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
padding: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
padding: $space-xs $space-sm;
|
||||||
|
border-bottom: 1rpx solid $border;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: #FFF5F5;
|
||||||
|
border-left: 4rpx solid $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
background: #FFFBF0;
|
||||||
|
border-left: 4rpx solid #F57C00;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-text {
|
||||||
|
font-size: $font-sm;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: $text;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-box, .empty-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $space-xl 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text, .empty-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: $space-md 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more-text {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bar-fill { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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 () => {
|
||||||
|
await waitForReady()
|
||||||
|
await loadNotifications(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeName(type: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
system: '系统',
|
||||||
|
group: '群组',
|
||||||
|
personal: '个人'
|
||||||
|
}
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
if (minutes < 1) return '刚刚'
|
||||||
|
if (minutes < 60) return `${minutes}分钟前`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}小时前`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days}天前`
|
||||||
|
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications(reset = false) {
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1
|
||||||
|
list.value = []
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await getNotifications({ page: page.value, pageSize: 20 })
|
||||||
|
if (reset) {
|
||||||
|
list.value = data.list
|
||||||
|
} else {
|
||||||
|
list.value = [...list.value, ...data.list]
|
||||||
|
}
|
||||||
|
total.value = data.total
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Load notifications error:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPublishModal() {
|
||||||
|
isEditing.value = false
|
||||||
|
editingId.value = null
|
||||||
|
form.value = { ...defaultForm }
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(item: Notification) {
|
||||||
|
isEditing.value = true
|
||||||
|
editingId.value = item.id
|
||||||
|
form.value = {
|
||||||
|
title: item.title,
|
||||||
|
content: item.content || '',
|
||||||
|
is_pinned: !!item.is_pinned,
|
||||||
|
is_urgent: !!item.is_urgent,
|
||||||
|
link_url: item.link_url || '',
|
||||||
|
image_url: item.image_url || '',
|
||||||
|
publish_at: item.publish_at || '',
|
||||||
|
expire_at: item.expire_at || ''
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择配图 */
|
||||||
|
function chooseImage() {
|
||||||
|
uni.chooseImage({
|
||||||
|
count: 1,
|
||||||
|
sizeType: ['compressed'],
|
||||||
|
success: (res) => {
|
||||||
|
form.value.image_url = res.tempFilePaths[0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取预览图片地址 */
|
||||||
|
function getPreviewImageSrc(url: string) {
|
||||||
|
if (!url) return ''
|
||||||
|
if (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('/')) return url
|
||||||
|
return getNotificationImageUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插入格式化标记 */
|
||||||
|
function insertFormat(prefix: string, suffix: string) {
|
||||||
|
// 简化实现:直接在内容末尾添加
|
||||||
|
form.value.content += prefix + '文本' + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传图片 */
|
||||||
|
async function uploadImageIfNeeded(imageUrl: string): Promise<string> {
|
||||||
|
if (!imageUrl) return ''
|
||||||
|
// 已经是服务器文件名(notif_ 开头)、完整 URL 或 blob URL,直接返回
|
||||||
|
if (imageUrl.startsWith('notif_') || imageUrl.startsWith('http') || imageUrl.startsWith('blob:')) {
|
||||||
|
return imageUrl
|
||||||
|
}
|
||||||
|
// 临时路径需要上传
|
||||||
|
uni.showLoading({ title: '上传图片中...' })
|
||||||
|
try {
|
||||||
|
const result = await uploadNotificationImage(imageUrl)
|
||||||
|
return result.image_url
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!form.value.title.trim()) {
|
||||||
|
uni.showToast({ title: '请输入标题', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageUrl = await uploadImageIfNeeded(form.value.image_url)
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
content: form.value.content.trim(),
|
||||||
|
is_pinned: form.value.is_pinned,
|
||||||
|
is_urgent: form.value.is_urgent,
|
||||||
|
link_url: form.value.link_url.trim() || undefined,
|
||||||
|
image_url: imageUrl || undefined,
|
||||||
|
publish_at: form.value.publish_at || undefined,
|
||||||
|
expire_at: form.value.expire_at || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && editingId.value) {
|
||||||
|
await updateNotification(editingId.value, data)
|
||||||
|
uni.showToast({ title: '已更新', icon: 'success' })
|
||||||
|
} else {
|
||||||
|
await publishNotification(data)
|
||||||
|
uni.showToast({ title: '已发布', icon: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
showModal.value = false
|
||||||
|
await loadNotifications(true)
|
||||||
|
} catch (e: any) {
|
||||||
|
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTogglePin(item: Notification) {
|
||||||
|
try {
|
||||||
|
await updateNotification(item.id, { is_pinned: !item.is_pinned })
|
||||||
|
item.is_pinned = item.is_pinned ? 0 : 1
|
||||||
|
uni.showToast({ title: item.is_pinned ? '已置顶' : '已取消置顶', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(item: Notification) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除公告',
|
||||||
|
content: `确定删除「${item.title}」?`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await deleteNotification(item.id)
|
||||||
|
list.value = list.value.filter(n => n.id !== item.id)
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await loadNotifications(true)
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (noMore.value || loading.value) return
|
||||||
|
page.value++
|
||||||
|
loadNotifications(false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
@include page-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fixed {
|
||||||
|
@include sticky-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm $space-xl $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
@include nav-back;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-action {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
@include flex-center;
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-box, .empty-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text, .empty-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-btn {
|
||||||
|
margin-top: $space-lg;
|
||||||
|
padding: $space-sm $space-xl;
|
||||||
|
background: $primary;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-btn-text {
|
||||||
|
font-size: $font-base;
|
||||||
|
color: $surface;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list {
|
||||||
|
padding: 0 $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
padding: $space-lg;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
|
||||||
|
&.pinned {
|
||||||
|
border-color: $primary;
|
||||||
|
background: #FFFAF7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: $font-xs;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
|
||||||
|
&.pin {
|
||||||
|
background: #FFE8E0;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.urgent {
|
||||||
|
background: #FFF0E6;
|
||||||
|
color: #E67355;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.type {
|
||||||
|
background: $bg;
|
||||||
|
color: $text-sec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-time {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-content {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-image {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-footer {
|
||||||
|
border-top: 2rpx solid $border;
|
||||||
|
padding-top: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-xs;
|
||||||
|
padding: $space-xs $space-sm;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
&:active { background: $bg; }
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
.action-text.danger { color: $danger; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-text {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: $space-lg 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹窗样式 */
|
||||||
|
.modal-mask {
|
||||||
|
@include modal-mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: $space-lg $space-xl $space-md;
|
||||||
|
border-bottom: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: $font-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
@include flex-center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $border;
|
||||||
|
&:active { background: darken($border, 5%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-text {
|
||||||
|
font-size: $font-2xl;
|
||||||
|
color: $text-sec;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: $space-md $space-xl;
|
||||||
|
max-height: 65vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: $font-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
height: 80rpx;
|
||||||
|
padding: 0 $space-md;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-xs;
|
||||||
|
padding: $space-xs;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: $radius-lg $radius-lg 0 0;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
@include flex-center;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
&:active { background: $border; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text;
|
||||||
|
&.bold { font-weight: 700; }
|
||||||
|
&.italic { font-style: italic; }
|
||||||
|
&.strike { text-decoration: line-through; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 200rpx;
|
||||||
|
padding: $space-sm $space-md;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text;
|
||||||
|
|
||||||
|
&.rich {
|
||||||
|
border-radius: 0 0 $radius-lg $radius-lg;
|
||||||
|
min-height: 300rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $text-muted;
|
||||||
|
margin-top: $space-xs;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: $space-sm $space-lg;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
&:active { background: $border; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-label {
|
||||||
|
font-size: $font-base;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-toggle {
|
||||||
|
width: 44rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #D0C4C4;
|
||||||
|
position: relative;
|
||||||
|
transition: background $transition-normal;
|
||||||
|
|
||||||
|
&.on { background: $primary; }
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2rpx;
|
||||||
|
left: 2rpx;
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $surface;
|
||||||
|
transition: transform $transition-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.on::after { transform: translateX(20rpx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: $space-md $space-xl $space-xl;
|
||||||
|
border-top: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn-text {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $surface;
|
||||||
|
padding: $space-lg;
|
||||||
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
border: 2rpx dashed #D0C4C4;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $space-xs;
|
||||||
|
&:active { background: $bg; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: -12rpx;
|
||||||
|
right: -12rpx;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
background: $danger;
|
||||||
|
border-radius: 50%;
|
||||||
|
@include flex-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-remove-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $surface;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
.placeholder { color: $text-muted; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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 () => {
|
||||||
|
await waitForReady()
|
||||||
|
await Promise.all([loadStats(), loadEvents()])
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadStats()
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await Promise.all([loadStats(), loadEvents()])
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
stats.value = await getTrackStats()
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvents(append = false) {
|
||||||
|
if (loading.value) return
|
||||||
|
if (!append) {
|
||||||
|
page.value = 1
|
||||||
|
eventList.value = []
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await queryTrackEvents({
|
||||||
|
event: filterEvent.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: 50
|
||||||
|
})
|
||||||
|
if (append) {
|
||||||
|
eventList.value = [...eventList.value, ...data.list]
|
||||||
|
} else {
|
||||||
|
eventList.value = data.list
|
||||||
|
}
|
||||||
|
total.value = data.total
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
if (noMore.value || loading.value) return
|
||||||
|
page.value++
|
||||||
|
loadEvents(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByEvent(event: string) {
|
||||||
|
filterEvent.value = event
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDateChange(e: any) {
|
||||||
|
filterDate.value = e.detail.value
|
||||||
|
loadEvents()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBarWidth(count: number): number {
|
||||||
|
if (!stats.value || stats.value.daily.length === 0) return 0
|
||||||
|
const max = Math.max(...stats.value.daily.map(d => d.count), 1)
|
||||||
|
return (count / max) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventBarWidth(count: number): number {
|
||||||
|
if (!stats.value || stats.value.events.length === 0) return 0
|
||||||
|
const max = Math.max(...stats.value.events.map(e => e.count), 1)
|
||||||
|
return (count / max) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEventClass(event: string): string {
|
||||||
|
if (event === 'error' || event === 'api_error') return 'error'
|
||||||
|
if (event === 'action') return 'action'
|
||||||
|
return 'page'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取事件类型中文名称 */
|
||||||
|
function getEventName(event: string): string {
|
||||||
|
const nameMap: Record<string, string> = {
|
||||||
|
page_view: '页面访问',
|
||||||
|
action: '用户操作',
|
||||||
|
error: '错误',
|
||||||
|
api_error: 'API错误',
|
||||||
|
app_launch: '应用启动'
|
||||||
|
}
|
||||||
|
return nameMap[event] || event
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化日期(处理 MySQL DATE 类型的 ISO 格式) */
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
// 处理 2026-06-08T00:00:00.000Z 格式
|
||||||
|
const datePart = dateStr.split('T')[0]
|
||||||
|
const parts = datePart.split('-')
|
||||||
|
if (parts.length === 3) {
|
||||||
|
return `${parseInt(parts[1])}/${parseInt(parts[2])}`
|
||||||
|
}
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
if (minutes < 1) return '刚刚'
|
||||||
|
if (minutes < 60) return `${minutes}分钟前`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}小时前`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days}天前`
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选条件变化时重新加载
|
||||||
|
watch(filterEvent, () => {
|
||||||
|
page.value = 1
|
||||||
|
loadEvents()
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fixed {
|
||||||
|
@include sticky-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
@include status-bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm $space-xl $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
@include nav-back;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder {
|
||||||
|
width: 88rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
margin: $space-md $space-xl;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
padding: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
padding: $space-lg 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
padding: $space-xs 0;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-tag {
|
||||||
|
padding: 2rpx 12rpx;
|
||||||
|
border-radius: $radius-xs;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.page {
|
||||||
|
background: #E3F2FD;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.action {
|
||||||
|
background: #E8F5E9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: #FFEBEE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-tag-text {
|
||||||
|
font-size: $font-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.page & {
|
||||||
|
color: #1976D2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action & {
|
||||||
|
color: #388E3C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error & {
|
||||||
|
color: #D32F2F;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 16rpx;
|
||||||
|
background: $border;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, $primary, #FFB89A);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-count {
|
||||||
|
width: 80rpx;
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
padding: $space-xs 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-date {
|
||||||
|
width: 80rpx;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 16rpx;
|
||||||
|
background: $border;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-count {
|
||||||
|
width: 80rpx;
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-section {
|
||||||
|
margin: $space-md $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-sm;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-sm;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-xs;
|
||||||
|
padding: $space-xs $space-md;
|
||||||
|
height: 64rpx;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-text {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
padding: 0 $space-md;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
font-size: $font-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
height: 64rpx;
|
||||||
|
padding: 0 $space-md;
|
||||||
|
background: $primary;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn-text {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $surface;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-xs;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
padding: $space-xs $space-md;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
background: $surface-warm;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $primary;
|
||||||
|
color: $surface;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-record-list {
|
||||||
|
height: calc(100vh - 800rpx);
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
padding: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-item {
|
||||||
|
padding: $space-md;
|
||||||
|
border-bottom: 1rpx solid $border;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-event {
|
||||||
|
padding: 2rpx 12rpx;
|
||||||
|
border-radius: $radius-xs;
|
||||||
|
|
||||||
|
&.page {
|
||||||
|
background: #E3F2FD;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.action {
|
||||||
|
background: #E8F5E9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: #FFEBEE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-event-text {
|
||||||
|
font-size: $font-xs;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.page & {
|
||||||
|
color: #1976D2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action & {
|
||||||
|
color: #388E3C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error & {
|
||||||
|
color: #D32F2F;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-time {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-page {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-sec;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-user {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $primary;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-data {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $text-muted;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
display: block;
|
||||||
|
padding: $space-xs;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-box,
|
||||||
|
.empty-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $space-xl 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: $space-md 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more-text {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bar-fill {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
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 { waitForReady } from '@/utils/app-ready'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight } from '@/utils/system'
|
||||||
import { API_BASE } from '@/config'
|
import { API_BASE } from '@/config'
|
||||||
@@ -71,9 +71,22 @@ const page = ref(1)
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const initialLoaded = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
await loadUsers(true)
|
await loadUsers(true)
|
||||||
|
initialLoaded.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 返回页面时静默刷新
|
||||||
|
onShow(() => {
|
||||||
|
if (initialLoaded.value) loadUsers(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await loadUsers(true)
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadUsers(reset = false) {
|
async function loadUsers(reset = false) {
|
||||||
@@ -83,7 +96,7 @@ async function loadUsers(reset = false) {
|
|||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const params: any = { page: page.value, pageSize: 20 }
|
const params: { page: number; pageSize: number; keyword?: string } = { page: page.value, pageSize: 20 }
|
||||||
if (keyword.value.trim()) params.keyword = keyword.value.trim()
|
if (keyword.value.trim()) params.keyword = keyword.value.trim()
|
||||||
const data = await getUsers(params)
|
const data = await getUsers(params)
|
||||||
if (reset) {
|
if (reset) {
|
||||||
@@ -162,86 +175,77 @@ onReachBottom(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
@include page-base;
|
||||||
background: #FFF8F0;
|
|
||||||
padding: 0 0 180rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
position: sticky;
|
@include sticky-header;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar { background: #FFF8F0; }
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
.nav-bar {
|
.nav-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 40rpx 24rpx;
|
padding: $space-sm $space-xl $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-back {
|
.nav-back {
|
||||||
width: 88rpx;
|
@include nav-back;
|
||||||
height: 88rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
&:active { opacity: 0.6; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-title {
|
.nav-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 36rpx;
|
font-size: $font-2xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-placeholder { width: 88rpx; }
|
.nav-placeholder { width: 88rpx; }
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
padding: 0 40rpx 24rpx;
|
padding: 0 $space-xl $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
padding: 0 24rpx;
|
padding: 0 $space-md;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-box, .empty-box {
|
.loading-box, .empty-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
padding: 120rpx 0;
|
padding: 120rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-text, .empty-text {
|
.loading-text, .empty-text {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-list { padding: 0 40rpx; }
|
.user-list { padding: 0 $space-xl; }
|
||||||
|
|
||||||
.user-item {
|
.user-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 20rpx;
|
gap: $space-lg;
|
||||||
padding: 24rpx 28rpx;
|
padding: $space-md $font-lg;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 28rpx;
|
border-radius: $radius-xl;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: $space-sm;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +253,7 @@ onReachBottom(() => {
|
|||||||
width: 72rpx;
|
width: 72rpx;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #F0E0D6;
|
background: $border;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,9 +266,9 @@ onReachBottom(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-name {
|
.user-name {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -272,24 +276,24 @@ onReachBottom(() => {
|
|||||||
|
|
||||||
.role-tag {
|
.role-tag {
|
||||||
padding: 2rpx 12rpx;
|
padding: 2rpx 12rpx;
|
||||||
border-radius: 8rpx;
|
border-radius: $radius-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-tag-text {
|
.role-tag-text {
|
||||||
font-size: 20rpx;
|
font-size: $font-xs;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-meta {
|
.user-meta {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4rpx;
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-time {
|
.user-time {
|
||||||
font-size: 22rpx;
|
font-size: $font-sm;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4rpx;
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
@@ -297,23 +301,23 @@ onReachBottom(() => {
|
|||||||
.user-actions {
|
.user-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8rpx;
|
gap: $space-xs;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
padding: 8rpx 16rpx;
|
padding: $space-xs $space-sm;
|
||||||
background: #FFF0E6;
|
background: $surface-warm;
|
||||||
border-radius: 12rpx;
|
border-radius: $radius-sm;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
&.danger { background: #FFE8E8; }
|
&.danger { background: $danger-light; }
|
||||||
&:active { opacity: 0.7; }
|
&:active { opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-text {
|
.action-text {
|
||||||
font-size: 22rpx;
|
font-size: $font-sm;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
&.danger-text { color: #FF6B6B; }
|
&.danger-text { color: $danger; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -213,24 +213,21 @@ function handleReset() {
|
|||||||
|
|
||||||
function handleApply() {
|
function handleApply() {
|
||||||
const filters: FilterParams = { ...localFilters };
|
const filters: FilterParams = { ...localFilters };
|
||||||
// 转换金额:元 → 分
|
// 转换金额:元 → 分,防御 NaN
|
||||||
filters.minAmount = minAmountStr.value
|
const minVal = parseFloat(minAmountStr.value);
|
||||||
? Math.round(parseFloat(minAmountStr.value) * 100)
|
const maxVal = parseFloat(maxAmountStr.value);
|
||||||
: 0;
|
filters.minAmount = minAmountStr.value && !isNaN(minVal) ? Math.round(minVal * 100) : 0;
|
||||||
filters.maxAmount = maxAmountStr.value
|
filters.maxAmount = maxAmountStr.value && !isNaN(maxVal) ? Math.round(maxVal * 100) : 0;
|
||||||
? Math.round(parseFloat(maxAmountStr.value) * 100)
|
|
||||||
: 0;
|
|
||||||
emit("apply", filters);
|
emit("apply", filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const filters: FilterParams = { ...localFilters };
|
const filters: FilterParams = { ...localFilters };
|
||||||
filters.minAmount = minAmountStr.value
|
// 转换金额:元 → 分,防御 NaN
|
||||||
? Math.round(parseFloat(minAmountStr.value) * 100)
|
const minVal = parseFloat(minAmountStr.value);
|
||||||
: 0;
|
const maxVal = parseFloat(maxAmountStr.value);
|
||||||
filters.maxAmount = maxAmountStr.value
|
filters.minAmount = minAmountStr.value && !isNaN(minVal) ? Math.round(minVal * 100) : 0;
|
||||||
? Math.round(parseFloat(maxAmountStr.value) * 100)
|
filters.maxAmount = maxAmountStr.value && !isNaN(maxVal) ? Math.round(maxVal * 100) : 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: "保存方案",
|
title: "保存方案",
|
||||||
@@ -271,26 +268,19 @@ async function handleDeleteSaved(id: number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.filter-mask {
|
.filter-mask {
|
||||||
position: fixed;
|
@include modal-mask;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-panel {
|
.filter-panel {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
background: #ffffff;
|
background: $surface;
|
||||||
border-radius: 40rpx 40rpx 0 0;
|
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,75 +288,70 @@ async function handleDeleteSaved(id: number) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 32rpx 40rpx 24rpx;
|
padding: $space-lg $space-xl $space-md;
|
||||||
border-bottom: 2rpx solid #f0e0d6;
|
border-bottom: 2rpx solid $border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-title {
|
.filter-title {
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2d1b1b;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-close {
|
.filter-close {
|
||||||
width: 56rpx;
|
width: 56rpx;
|
||||||
height: 56rpx;
|
height: 56rpx;
|
||||||
display: flex;
|
@include flex-center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #f0e0d6;
|
background: $border;
|
||||||
&:active {
|
&:active {
|
||||||
background: #e0d6d0;
|
background: darken($border, 5%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-close-text {
|
.filter-close-text {
|
||||||
font-size: 36rpx;
|
font-size: $font-2xl;
|
||||||
color: #8b7e7e;
|
color: $text-sec;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-body {
|
.filter-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 24rpx 40rpx;
|
padding: $space-md $space-xl;
|
||||||
max-height: 55vh;
|
max-height: 55vh;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-section {
|
.filter-section {
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: $space-lg;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-label {
|
.section-label {
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2d1b1b;
|
color: $text;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-row {
|
.chip-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12rpx;
|
gap: $space-xs;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
padding: 12rpx 24rpx;
|
padding: $space-xs $space-md;
|
||||||
background: #fff8f0;
|
background: $bg;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-xl;
|
||||||
border: 2rpx solid #f0e0d6;
|
border: 2rpx solid $border;
|
||||||
transition: all 0.2s;
|
transition: all $transition-normal;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #ffe8e0;
|
background: $primary-light;
|
||||||
border-color: #ff8c69;
|
border-color: $primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
@@ -375,10 +360,10 @@ async function handleDeleteSaved(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip-text {
|
.chip-text {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #2d1b1b;
|
color: $text;
|
||||||
.active & {
|
.active & {
|
||||||
color: #ff8c69;
|
color: $primary;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,130 +371,123 @@ async function handleDeleteSaved(id: number) {
|
|||||||
.date-row {
|
.date-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-picker {
|
.date-picker {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
padding: 0 20rpx;
|
padding: 0 $space-md;
|
||||||
background: #fff8f0;
|
background: $bg;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-lg;
|
||||||
border: 2rpx solid #f0e0d6;
|
border: 2rpx solid $border;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-text {
|
.date-text {
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
color: #2d1b1b;
|
color: $text;
|
||||||
&.placeholder {
|
&.placeholder {
|
||||||
color: #bfb3b3;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-sep {
|
.date-sep {
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
color: #8b7e7e;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-row {
|
.amount-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-input {
|
.amount-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
padding: 0 20rpx;
|
padding: 0 $space-md;
|
||||||
background: #fff8f0;
|
background: $bg;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-lg;
|
||||||
border: 2rpx solid #f0e0d6;
|
border: 2rpx solid $border;
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
color: #2d1b1b;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-sep {
|
.amount-sep {
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
color: #8b7e7e;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-input {
|
.keyword-input {
|
||||||
width: 100%;
|
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
padding: 0 20rpx;
|
padding: 0 $space-md;
|
||||||
background: #fff8f0;
|
background: $bg;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-lg;
|
||||||
border: 2rpx solid #f0e0d6;
|
border: 2rpx solid $border;
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
color: #2d1b1b;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-item {
|
.saved-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16rpx 20rpx;
|
padding: $space-sm $space-md;
|
||||||
background: #fff8f0;
|
background: $bg;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-lg;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: $space-xs;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background: #f0e0d6;
|
background: $border;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-name {
|
.saved-name {
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
color: #2d1b1b;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-delete {
|
.saved-delete {
|
||||||
width: 40rpx;
|
width: 40rpx;
|
||||||
height: 40rpx;
|
height: 40rpx;
|
||||||
display: flex;
|
@include flex-center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
&:active {
|
&:active {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.saved-delete-text {
|
.saved-delete-text {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #bfb3b3;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-footer {
|
.filter-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
padding: 24rpx 40rpx 40rpx;
|
padding: $space-md $space-xl $space-xl;
|
||||||
border-top: 2rpx solid #f0e0d6;
|
border-top: 2rpx solid $border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-btn {
|
.footer-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-xl;
|
||||||
display: flex;
|
@include flex-center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&.reset {
|
&.reset {
|
||||||
background: #fff8f0;
|
background: $bg;
|
||||||
border: 2rpx solid #f0e0d6;
|
border: 2rpx solid $border;
|
||||||
}
|
}
|
||||||
&.save {
|
&.save {
|
||||||
background: #fff0e6;
|
background: $surface-warm;
|
||||||
border: 2rpx solid #ff8c69;
|
border: 2rpx solid $primary;
|
||||||
}
|
}
|
||||||
&.apply {
|
&.apply {
|
||||||
background: linear-gradient(135deg, #ff8c69, #e67355);
|
background: linear-gradient(135deg, $primary, $primary-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
@@ -518,16 +496,16 @@ async function handleDeleteSaved(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-btn-text {
|
.footer-btn-text {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
&.reset-text {
|
&.reset-text {
|
||||||
color: #8b7e7e;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
&.save-text {
|
&.save-text {
|
||||||
color: #ff8c69;
|
color: $primary;
|
||||||
}
|
}
|
||||||
&.apply-text {
|
&.apply-text {
|
||||||
color: #ffffff;
|
color: $surface;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<!-- 筛选面板 -->
|
<!-- 筛选面板 -->
|
||||||
<FilterPanel v-if="showFilter" :initialFilters="advancedFilters" @close="showFilter = false" @apply="onFilterApply" />
|
<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 v-for="group in groupedTx" :key="group.date" class="date-group">
|
||||||
<view class="group-header">
|
<view class="group-header">
|
||||||
<text class="group-date">{{ group.label }}</text>
|
<text class="group-date">{{ group.label }}</text>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</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 v-for="g in 2" :key="g" class="date-group">
|
||||||
<view class="group-header">
|
<view class="group-header">
|
||||||
<Skeleton width="120rpx" height="26rpx" variant="text" />
|
<Skeleton width="120rpx" height="26rpx" variant="text" />
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</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>
|
<text class="loading-text">加载中...</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -79,8 +79,9 @@
|
|||||||
<text class="state-text">加载失败,下拉刷新重试</text>
|
<text class="state-text">加载失败,下拉刷新重试</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="empty" v-if="txList.length === 0 && !txStore.loading && !loadError">
|
<view class="state-box" v-if="txList.length === 0 && !loading && !loadError">
|
||||||
<text class="empty-text">暂无账单记录</text>
|
<Icon name="edit" :size="48" color="#BFB3B3" />
|
||||||
|
<text class="state-text">暂无账单记录</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -88,25 +89,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
import { useTransactionStore } from '@/stores/transaction'
|
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
|
import { getTransactions, deleteTransaction } from '@/api/transaction'
|
||||||
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||||
import FilterPanel from './filter-panel.vue'
|
import FilterPanel from './filter-panel.vue'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight } from '@/utils/system'
|
||||||
import { formatAmount, formatDate } from '@/utils/format'
|
import { formatAmount, formatDate } from '@/utils/format'
|
||||||
|
import type { Transaction, TransactionParams } from '@/api/transaction'
|
||||||
import type { FilterParams } from '@/api/filter'
|
import type { FilterParams } from '@/api/filter'
|
||||||
|
|
||||||
const txStore = useTransactionStore()
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
const loading = ref(false)
|
||||||
const filterType = ref('all')
|
const filterType = ref('all')
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
const txList = ref<any[]>([])
|
const txList = ref<Transaction[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
let loadSeq = 0 // 请求序号,防止并发覆盖
|
let loadSeq = 0 // 请求序号,防止并发覆盖
|
||||||
@@ -162,7 +164,7 @@ async function loadTx(reset = false, silent = false) {
|
|||||||
if (!silent) txList.value = []
|
if (!silent) txList.value = []
|
||||||
}
|
}
|
||||||
const seq = ++loadSeq
|
const seq = ++loadSeq
|
||||||
const params: any = { page: page.value, pageSize }
|
const params: TransactionParams & { group_id?: number | null; category_ids?: string } = { page: page.value, pageSize, group_id: groupStore.currentGroupId }
|
||||||
if (filterType.value !== 'all') params.type = filterType.value
|
if (filterType.value !== 'all') params.type = filterType.value
|
||||||
// 合并高级筛选参数
|
// 合并高级筛选参数
|
||||||
const af = advancedFilters.value
|
const af = advancedFilters.value
|
||||||
@@ -173,24 +175,25 @@ async function loadTx(reset = false, silent = false) {
|
|||||||
if (af.maxAmount) params.maxAmount = af.maxAmount
|
if (af.maxAmount) params.maxAmount = af.maxAmount
|
||||||
if (af.keyword) params.keyword = af.keyword
|
if (af.keyword) params.keyword = af.keyword
|
||||||
try {
|
try {
|
||||||
|
loading.value = true
|
||||||
loadError.value = false
|
loadError.value = false
|
||||||
const data = await txStore.fetchTransactions(params)
|
const data = await getTransactions(params)
|
||||||
// 丢弃过期请求的结果
|
// 丢弃过期请求的结果
|
||||||
if (seq !== loadSeq) return
|
if (seq !== loadSeq) return
|
||||||
if (reset) {
|
if (reset) {
|
||||||
txList.value = txStore.transactions
|
txList.value = data.list
|
||||||
} else {
|
} else {
|
||||||
txList.value = [...txList.value, ...txStore.transactions]
|
txList.value = [...txList.value, ...data.list]
|
||||||
}
|
}
|
||||||
total.value = txStore.total
|
total.value = data.total
|
||||||
if (data) {
|
|
||||||
filteredExpense.value = data.filteredExpense || 0
|
filteredExpense.value = data.filteredExpense || 0
|
||||||
filteredIncome.value = data.filteredIncome || 0
|
filteredIncome.value = data.filteredIncome || 0
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (seq !== loadSeq) return
|
if (seq !== loadSeq) return
|
||||||
console.error('Load bills error:', e)
|
console.error('Load bills error:', e)
|
||||||
loadError.value = true
|
loadError.value = true
|
||||||
|
} finally {
|
||||||
|
if (seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,9 +222,15 @@ async function onDelete(item: any) {
|
|||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
try {
|
try {
|
||||||
await txStore.deleteTransaction(item.id)
|
await deleteTransaction(item.id)
|
||||||
txList.value = txList.value.filter(t => t.id !== item.id)
|
txList.value = txList.value.filter(t => t.id !== item.id)
|
||||||
total.value--
|
total.value--
|
||||||
|
// 更新筛选金额
|
||||||
|
if (item.type === 'expense') {
|
||||||
|
filteredExpense.value = Math.max(0, filteredExpense.value - item.amount)
|
||||||
|
} else {
|
||||||
|
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
|
||||||
|
}
|
||||||
uni.showToast({ title: '已删除', icon: 'success' })
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
} catch {
|
} catch {
|
||||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||||
@@ -233,7 +242,7 @@ async function onDelete(item: any) {
|
|||||||
|
|
||||||
// 触底加载更多
|
// 触底加载更多
|
||||||
onReachBottom(() => {
|
onReachBottom(() => {
|
||||||
if (noMore.value || txStore.loading) return
|
if (noMore.value || loading.value) return
|
||||||
page.value++
|
page.value++
|
||||||
loadTx(false)
|
loadTx(false)
|
||||||
})
|
})
|
||||||
@@ -245,60 +254,36 @@ onPullDownRefresh(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
@include page-base;
|
||||||
background: #FFF8F0;
|
|
||||||
padding: 0 0 180rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
position: sticky;
|
@include sticky-header;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
background: #FFF8F0;
|
@include status-bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
display: block;
|
@include page-title;
|
||||||
text-align: center;
|
|
||||||
font-size: 36rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2D1B1B;
|
|
||||||
padding: 16rpx 0 24rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs-wrap {
|
.tabs-wrap {
|
||||||
display: flex;
|
@include tabs-wrap;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0 0 24rpx;
|
margin: 0 0 $space-md;
|
||||||
gap: 16rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: inline-flex;
|
@include tabs;
|
||||||
background: #FFF0E6;
|
|
||||||
border-radius: 24rpx;
|
|
||||||
padding: 8rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
padding: 16rpx 40rpx;
|
@include tab;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
@@ -306,104 +291,92 @@ onPullDownRefresh(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6rpx;
|
gap: 6rpx;
|
||||||
padding: 12rpx 20rpx;
|
padding: 12rpx 20rpx;
|
||||||
background: #FFF0E6;
|
background: $surface-warm;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: #FF8C69;
|
border-color: $primary;
|
||||||
background: #FFE8E0;
|
background: $primary-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active { opacity: 0.7; }
|
&:active { opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn-text {
|
.filter-btn-text {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
.active & { color: #FF8C69; }
|
.active & { color: $primary; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-stats {
|
.filter-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
padding: 0 40rpx 16rpx;
|
padding: 0 $space-xl $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-stats-text {
|
.filter-stats-text {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tx-list { padding: 0 40rpx; }
|
.tx-list { padding: 0 $space-xl; }
|
||||||
|
|
||||||
.date-group {
|
.date-group {
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-header {
|
.group-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 0;
|
padding: $space-sm 0;
|
||||||
border-bottom: 1rpx solid #F0E0D6;
|
border-bottom: 1rpx solid $border;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-date {
|
.group-date {
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-total {
|
.group-total {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
|
|
||||||
&.expense { color: #2D1B1B; }
|
&.expense { color: $text; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-item {
|
.skeleton-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 28rpx 0;
|
padding: 28rpx 0;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-info {
|
.skeleton-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8rpx;
|
gap: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-more {
|
.loading-more {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 32rpx 0;
|
padding: $space-lg 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-text { font-size: 24rpx; color: #BFB3B3; }
|
.loading-text { font-size: $font-md; color: $text-muted; }
|
||||||
|
|
||||||
.empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 120rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text { font-size: 28rpx; color: #BFB3B3; }
|
|
||||||
|
|
||||||
.state-box {
|
.state-box {
|
||||||
display: flex;
|
@include state-box;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 16rpx;
|
|
||||||
padding: 120rpx 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-text {
|
.state-text {
|
||||||
font-size: 28rpx;
|
@include state-text;
|
||||||
color: #BFB3B3;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -60,42 +60,54 @@
|
|||||||
|
|
||||||
<!-- 自定义金额 -->
|
<!-- 自定义金额 -->
|
||||||
<view class="section-title">自定义金额</view>
|
<view class="section-title">自定义金额</view>
|
||||||
<view class="custom-input">
|
<AmountEditor
|
||||||
<text class="input-prefix">¥</text>
|
ref="amountEditorRef"
|
||||||
<text class="input-value" :class="{ placeholder: !amountStr }">{{ amountStr }}</text>
|
v-model="amountStr"
|
||||||
<text class="input-cursor">|</text>
|
:fixed="true"
|
||||||
<text class="input-unit">元</text>
|
size="large"
|
||||||
|
unit="元"
|
||||||
|
:auto-focus="false"
|
||||||
|
@focus="keyboardVisible = true"
|
||||||
|
@blur="keyboardVisible = false"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Numpad -->
|
<!-- 底部保存按钮(键盘收起时显示) -->
|
||||||
<Numpad v-model="amountStr" @confirm="onConfirm" />
|
<view class="save-bar" v-if="!keyboardVisible">
|
||||||
|
<view class="save-btn" @tap="onConfirm">
|
||||||
|
<text class="save-btn-text">保存预算</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
import { useBudgetStore } from '@/stores/budget'
|
import { useBudgetStore } from '@/stores/budget'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
import { getOverview } from '@/api/stats'
|
import { getOverview } from '@/api/stats'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { formatAmount, getCurrentMonth } from '@/utils/format'
|
import { formatAmount, getCurrentMonth } from '@/utils/format'
|
||||||
|
import type { Budget } from '@/api/budget'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight } from '@/utils/system'
|
||||||
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
|
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
|
||||||
import Numpad from '@/components/Numpad/Numpad.vue'
|
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
|
||||||
const budgetStore = useBudgetStore()
|
const budgetStore = useBudgetStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
const currentMonth = getCurrentMonth()
|
const currentMonth = getCurrentMonth()
|
||||||
const budget = ref<any>(null)
|
const budget = ref<Budget | null>(null)
|
||||||
const budgetAmount = ref(0)
|
const budgetAmount = ref(0)
|
||||||
const monthExpense = ref(0)
|
const monthExpense = ref(0)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const initialLoaded = ref(false)
|
const initialLoaded = ref(false)
|
||||||
const amountStr = ref('')
|
const amountStr = ref('')
|
||||||
|
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
|
||||||
|
const keyboardVisible = ref(false)
|
||||||
|
|
||||||
const presets = [1000, 2000, 3000, 5000, 10000]
|
const presets = [1000, 2000, 3000, 5000, 10000]
|
||||||
|
|
||||||
@@ -150,8 +162,18 @@ onShow(async () => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
try {
|
||||||
|
await budgetStore.fetchBudget(currentMonth)
|
||||||
|
syncBudgetData()
|
||||||
|
fetchMyExpense()
|
||||||
|
} catch {}
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
function selectPreset(val: number) {
|
function selectPreset(val: number) {
|
||||||
amountStr.value = String(val)
|
amountStr.value = String(val)
|
||||||
|
amountEditorRef.value?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onConfirm() {
|
async function onConfirm() {
|
||||||
@@ -180,46 +202,45 @@ function goBack() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #FFF8F0;
|
background: $bg;
|
||||||
padding-bottom: 120rpx;
|
// AmountEditor fixed Numpad 占位 + 保存按钮占位
|
||||||
|
$numpad-h: 464rpx;
|
||||||
|
$save-bar-h: 144rpx;
|
||||||
|
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + constant(safe-area-inset-bottom));
|
||||||
|
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
position: sticky;
|
@include sticky-header;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
background: #FFF8F0;
|
@include status-bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-bar {
|
.nav-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 32rpx;
|
padding: $space-sm $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-back {
|
.nav-back {
|
||||||
width: 88rpx;
|
@include nav-back;
|
||||||
height: 88rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
||||||
&:active { background: #F0E0D6; }
|
&:active { background: $border; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-title {
|
.nav-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 36rpx;
|
font-size: $font-2xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-placeholder {
|
.nav-placeholder {
|
||||||
@@ -227,18 +248,18 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 0 40rpx;
|
padding: 0 $space-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-row {
|
.overview-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 28rpx;
|
border-radius: $radius-xl;
|
||||||
padding: 28rpx 32rpx;
|
padding: $font-lg $space-lg;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: $space-md;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-item {
|
.overview-item {
|
||||||
@@ -247,182 +268,160 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.overview-label {
|
.overview-label {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-value {
|
.overview-value {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
|
|
||||||
&.highlight { color: #FF8C69; }
|
&.highlight { color: $primary; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-divider {
|
.overview-divider {
|
||||||
width: 2rpx;
|
width: 2rpx;
|
||||||
height: 56rpx;
|
height: 56rpx;
|
||||||
background: #F0E0D6;
|
background: $border;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-card {
|
.preview-card {
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 40rpx;
|
border-radius: $radius-2xl;
|
||||||
padding: 48rpx;
|
padding: $space-2xl;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
margin-bottom: 48rpx;
|
margin-bottom: $space-2xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-header {
|
.preview-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-label {
|
.preview-label {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-month {
|
.preview-month {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-amount {
|
.preview-amount {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 56rpx;
|
font-size: $font-4xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-skeleton {
|
.preview-skeleton {
|
||||||
width: 240rpx;
|
width: 240rpx;
|
||||||
height: 64rpx;
|
height: 64rpx;
|
||||||
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
|
@include shimmer;
|
||||||
background-size: 200% 100%;
|
border-radius: $radius-sm;
|
||||||
border-radius: 12rpx;
|
margin-bottom: $space-md;
|
||||||
margin-bottom: 24rpx;
|
|
||||||
animation: shimmer 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-hint {
|
.preview-hint {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 16rpx 0;
|
padding: $space-sm 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-grid {
|
.preset-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 20rpx;
|
gap: $space-lg;
|
||||||
margin-bottom: 48rpx;
|
margin-bottom: $space-2xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-item {
|
.preset-item {
|
||||||
height: 96rpx;
|
height: 96rpx;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 28rpx;
|
border-radius: $radius-xl;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.2s;
|
transition: all $transition-normal;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background: #FFF8F0;
|
background: $bg;
|
||||||
border-color: #FF8C69;
|
border-color: $primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #FFE8E0;
|
background: $primary-light;
|
||||||
border-color: #FF8C69;
|
border-color: $primary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-text {
|
.preset-text {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-input {
|
// 底部保存按钮栏
|
||||||
|
.save-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: $space-md $space-xl;
|
||||||
|
padding-bottom: calc(#{$space-md} + constant(safe-area-inset-bottom));
|
||||||
|
padding-bottom: calc(#{$space-md} + env(safe-area-inset-bottom));
|
||||||
|
background: $surface;
|
||||||
|
border-top: 2rpx solid $border;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
height: 96rpx;
|
||||||
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
|
border-radius: $radius-xl;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 8rpx;
|
justify-content: center;
|
||||||
margin-bottom: 32rpx;
|
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
|
||||||
padding: 24rpx 32rpx;
|
|
||||||
background: #FFFFFF;
|
&:active {
|
||||||
border-radius: 28rpx;
|
opacity: 0.8;
|
||||||
border: 2rpx solid #F0E0D6;
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-prefix {
|
.save-btn-text {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-size: $font-xl;
|
||||||
font-size: 40rpx;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FF8C69;
|
color: $surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-value {
|
|
||||||
font-family: 'Fredoka', sans-serif;
|
|
||||||
font-size: 56rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2D1B1B;
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
&.placeholder {
|
|
||||||
color: #D0C4C4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-unit {
|
|
||||||
font-size: 28rpx;
|
|
||||||
color: #8B7E7E;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-cursor {
|
|
||||||
font-family: 'Fredoka', monospace;
|
|
||||||
font-size: 56rpx;
|
|
||||||
font-weight: 300;
|
|
||||||
color: #FF8C69;
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
50% { opacity: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.preview-skeleton {
|
.preview-skeleton {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
.input-cursor {
|
|
||||||
animation: none;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
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 { useCategoryStore, type Category } from '@/stores/category'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { getCategoryTransactionCount } from '@/api/category'
|
import { getCategoryTransactionCount } from '@/api/category'
|
||||||
@@ -158,6 +158,11 @@ onShow(() => {
|
|||||||
if (initialLoaded.value) catStore.fetchCategories()
|
if (initialLoaded.value) catStore.fetchCategories()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await catStore.fetchCategories()
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
@@ -268,73 +273,66 @@ async function onMigrateConfirm() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
@include page-base;
|
||||||
background: #FFF8F0;
|
|
||||||
padding: 0 0 180rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
position: sticky;
|
@include sticky-header;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar { background: #FFF8F0; }
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 40rpx;
|
padding: $space-sm $space-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-back {
|
.nav-back {
|
||||||
width: 88rpx;
|
@include nav-back;
|
||||||
height: 88rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
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 {
|
.tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
background: #FFF0E6;
|
background: $surface-warm;
|
||||||
border-radius: 24rpx;
|
border-radius: $space-md;
|
||||||
padding: 8rpx;
|
padding: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
padding: 16rpx 40rpx;
|
padding: $space-sm $space-xl;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
transition: all 0.2s;
|
transition: all $transition-normal;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
font-weight: 600;
|
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 {
|
.cat-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 24rpx 0;
|
padding: $space-md 0;
|
||||||
border-bottom: 1rpx solid #F0E0D6;
|
border-bottom: 1rpx solid $border;
|
||||||
|
|
||||||
&:last-child { border-bottom: none; }
|
&:last-child { border-bottom: none; }
|
||||||
}
|
}
|
||||||
@@ -342,26 +340,26 @@ async function onMigrateConfirm() {
|
|||||||
.cat-info {
|
.cat-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 8rpx 0;
|
padding: $space-xs 0;
|
||||||
|
|
||||||
&:active { opacity: 0.6; }
|
&: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 {
|
.cat-badge {
|
||||||
font-size: 20rpx;
|
font-size: $font-xs;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
background: #FFE8E0;
|
background: $primary-light;
|
||||||
padding: 4rpx 12rpx;
|
padding: 4rpx 12rpx;
|
||||||
border-radius: 8rpx;
|
border-radius: $radius-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cat-actions {
|
.cat-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8rpx;
|
gap: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
@@ -372,16 +370,16 @@ async function onMigrateConfirm() {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
||||||
&:active { background: #F0E0D6; }
|
&:active { background: $border; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab {
|
.fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 40rpx;
|
right: $space-xl;
|
||||||
bottom: 120rpx;
|
bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||||
width: 112rpx;
|
width: 112rpx;
|
||||||
height: 112rpx;
|
height: 112rpx;
|
||||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -394,8 +392,8 @@ async function onMigrateConfirm() {
|
|||||||
|
|
||||||
.color-picker {
|
.color-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20rpx;
|
gap: $space-lg;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: $space-md;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,10 +402,10 @@ async function onMigrateConfirm() {
|
|||||||
height: 56rpx;
|
height: 56rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 4rpx solid transparent;
|
border: 4rpx solid transparent;
|
||||||
transition: all 0.2s;
|
transition: all $transition-normal;
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
border-color: #2D1B1B;
|
border-color: $text;
|
||||||
transform: scale(1.15);
|
transform: scale(1.15);
|
||||||
}
|
}
|
||||||
&:active { opacity: 0.7; }
|
&:active { opacity: 0.7; }
|
||||||
@@ -425,87 +423,85 @@ async function onMigrateConfirm() {
|
|||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
width: 600rpx;
|
width: 600rpx;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 32rpx;
|
border-radius: $space-lg;
|
||||||
padding: 48rpx;
|
padding: $space-2xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-desc {
|
.modal-desc {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-input {
|
.modal-input {
|
||||||
width: 100%;
|
|
||||||
height: 96rpx;
|
height: 96rpx;
|
||||||
background: #FFF8F0;
|
background: $bg;
|
||||||
border-radius: 24rpx;
|
border-radius: $space-md;
|
||||||
padding: 0 32rpx;
|
padding: 0 $space-lg;
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: $space-md;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.migrate-list {
|
.migrate-list {
|
||||||
max-height: 400rpx;
|
max-height: 400rpx;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.migrate-item {
|
.migrate-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
padding: 20rpx 16rpx;
|
padding: $space-lg $space-sm;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-md;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: $space-xs;
|
||||||
transition: background 0.2s;
|
transition: background $transition-normal;
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
background: #FFE8E0;
|
background: $primary-light;
|
||||||
}
|
}
|
||||||
&:active { background: #FFF0E6; }
|
&:active { background: $surface-warm; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.migrate-name {
|
.migrate-name {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-btns {
|
.modal-btns {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-btn {
|
.modal-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: $space-md;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
&.cancel {
|
&.cancel {
|
||||||
background: #FFF8F0;
|
background: $bg;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.confirm {
|
&.confirm {
|
||||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
color: #FFFFFF;
|
color: $surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled { opacity: 0.5; }
|
&.disabled { opacity: 0.5; }
|
||||||
|
|||||||
262
client/src/pages/feedback/index.vue
Normal file
262
client/src/pages/feedback/index.vue
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="header-fixed">
|
||||||
|
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||||
|
<view class="nav-bar">
|
||||||
|
<view class="nav-back" @tap="goBack">
|
||||||
|
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
|
||||||
|
</view>
|
||||||
|
<text class="nav-title">意见反馈</text>
|
||||||
|
<view class="nav-placeholder"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="content">
|
||||||
|
<!-- 反馈类型 -->
|
||||||
|
<view class="section">
|
||||||
|
<text class="label">反馈类型</text>
|
||||||
|
<view class="type-grid">
|
||||||
|
<view
|
||||||
|
v-for="t in types"
|
||||||
|
:key="t.value"
|
||||||
|
class="type-item"
|
||||||
|
:class="{ active: form.type === t.value }"
|
||||||
|
@tap="form.type = t.value"
|
||||||
|
>
|
||||||
|
<text class="type-text">{{ t.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 反馈内容 -->
|
||||||
|
<view class="section">
|
||||||
|
<text class="label">问题描述</text>
|
||||||
|
<textarea
|
||||||
|
class="textarea"
|
||||||
|
v-model="form.content"
|
||||||
|
placeholder="请详细描述您遇到的问题或建议..."
|
||||||
|
maxlength="500"
|
||||||
|
:show-confirm-bar="false"
|
||||||
|
/>
|
||||||
|
<text class="char-count">{{ form.content.length }}/500</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 联系方式 -->
|
||||||
|
<view class="section">
|
||||||
|
<text class="label">联系方式(选填)</text>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
v-model="form.contact"
|
||||||
|
placeholder="微信号或邮箱,方便我们联系您"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 提交按钮 -->
|
||||||
|
<view class="submit-btn" :class="{ disabled: submitting || !form.content.trim() }" @tap="onSubmit">
|
||||||
|
<text class="submit-text">{{ submitting ? '提交中...' : '提交反馈' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="tip">感谢您的反馈,我们会认真处理每一条意见</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { statusBarHeight } from '@/utils/system'
|
||||||
|
import { submitFeedback } from '@/api/feedback'
|
||||||
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
|
||||||
|
const types = [
|
||||||
|
{ label: '功能异常', value: 'bug' },
|
||||||
|
{ label: '功能建议', value: 'feature' },
|
||||||
|
{ label: '体验问题', value: 'ux' },
|
||||||
|
{ label: '其他', value: 'other' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
type: 'bug',
|
||||||
|
content: '',
|
||||||
|
contact: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (submitting.value || !form.content.trim()) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await submitFeedback({
|
||||||
|
type: form.type,
|
||||||
|
content: form.content.trim(),
|
||||||
|
contact: form.contact.trim() || undefined
|
||||||
|
})
|
||||||
|
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 1500)
|
||||||
|
} catch (e: any) {
|
||||||
|
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fixed {
|
||||||
|
@include sticky-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
@include status-bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
@include nav-back;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&:active { background: $border; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder {
|
||||||
|
width: 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: $space-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-item {
|
||||||
|
padding: $space-sm $space-lg;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
transition: all $transition-normal;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $primary-light;
|
||||||
|
border-color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text-sec;
|
||||||
|
|
||||||
|
.active & {
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 300rpx;
|
||||||
|
padding: $space-lg;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text;
|
||||||
|
line-height: 1.6;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-muted;
|
||||||
|
margin-top: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 96rpx;
|
||||||
|
padding: 0 $space-lg;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 96rpx;
|
||||||
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: $space-2xl;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-text {
|
||||||
|
font-size: $font-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-muted;
|
||||||
|
margin-top: $space-lg;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -75,6 +75,11 @@
|
|||||||
<view class="invite-action" @tap="copyInviteCode(group.invite_code)">
|
<view class="invite-action" @tap="copyInviteCode(group.invite_code)">
|
||||||
<text class="invite-action-text">复制</text>
|
<text class="invite-action-text">复制</text>
|
||||||
</view>
|
</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)">
|
<view v-if="group.role === 'owner'" class="invite-action" @tap="handleRefreshCode(group)">
|
||||||
<Icon name="refresh" :size="24" color="#8B7E7E" />
|
<Icon name="refresh" :size="24" color="#8B7E7E" />
|
||||||
<text class="invite-action-text">刷新</text>
|
<text class="invite-action-text">刷新</text>
|
||||||
@@ -135,8 +140,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
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 { useGroupStore } from '@/stores/group'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||||
import { API_BASE } from '@/config'
|
import { API_BASE } from '@/config'
|
||||||
@@ -144,12 +150,14 @@ import Icon from '@/components/Icon/Icon.vue'
|
|||||||
import type { GroupMember, Group } from '@/api/group'
|
import type { GroupMember, Group } from '@/api/group'
|
||||||
|
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
const actionTab = ref<'create' | 'join'>('create')
|
const actionTab = ref<'create' | 'join'>('create')
|
||||||
const newGroupName = ref('')
|
const newGroupName = ref('')
|
||||||
const inviteCode = ref('')
|
const inviteCode = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const initialLoaded = ref(false)
|
const initialLoaded = ref(false)
|
||||||
|
const shareInviteCode = ref('') // 当前要分享的邀请码
|
||||||
|
|
||||||
// 成员管理弹窗
|
// 成员管理弹窗
|
||||||
const showMemberModal = ref(false)
|
const showMemberModal = ref(false)
|
||||||
@@ -168,6 +176,29 @@ onShow(() => {
|
|||||||
if (initialLoaded.value) groupStore.fetchGroups()
|
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() {
|
function goBack() {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
@@ -325,59 +356,50 @@ function handleDissolve(group: Group) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
@include page-base;
|
||||||
background: #FFF8F0;
|
|
||||||
padding: 0 0 180rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
position: sticky;
|
@include sticky-header;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar { background: #FFF8F0; }
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
.nav-bar {
|
.nav-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 40rpx 24rpx;
|
padding: $space-sm $space-xl $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-back {
|
.nav-back {
|
||||||
width: 88rpx;
|
@include nav-back;
|
||||||
height: 88rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:active { opacity: 0.6; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-title {
|
.nav-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 36rpx;
|
font-size: $font-2xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab 卡片 */
|
/* Tab 卡片 */
|
||||||
.action-card {
|
.action-card {
|
||||||
margin: 24rpx 40rpx 0;
|
margin: $space-md $space-xl 0;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 40rpx;
|
border-radius: $radius-2xl;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 16rpx 16rpx 0;
|
padding: $space-sm $space-sm 0;
|
||||||
gap: 8rpx;
|
gap: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
@@ -386,69 +408,69 @@ function handleDissolve(group: Group) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 20rpx 20rpx 0 0;
|
border-radius: $radius-lg $radius-lg 0 0;
|
||||||
transition: all 0.2s;
|
transition: all $transition-normal;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #FFF0E6;
|
background: $surface-warm;
|
||||||
|
|
||||||
.tab-text { color: #FF8C69; }
|
.tab-text { color: $primary; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-text {
|
.tab-text {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-body {
|
.action-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
padding: 24rpx 32rpx 32rpx;
|
padding: $space-md $space-lg $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-input {
|
.action-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
padding: 0 24rpx;
|
padding: 0 $space-md;
|
||||||
background: #FFF8F0;
|
background: $bg;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
padding: 0 32rpx;
|
padding: 0 $space-lg;
|
||||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8rpx;
|
gap: $space-xs;
|
||||||
|
|
||||||
&.disabled { opacity: 0.5; }
|
&.disabled { opacity: 0.5; }
|
||||||
&:active { opacity: 0.8; }
|
&:active { opacity: 0.8; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn-text {
|
.action-btn-text {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: $surface;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 群组列表 */
|
/* 群组列表 */
|
||||||
.section-header {
|
.section-header {
|
||||||
padding: 40rpx 40rpx 16rpx;
|
padding: $space-xl $space-xl $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-box, .empty-box {
|
.loading-box, .empty-box {
|
||||||
@@ -456,36 +478,36 @@ function handleDissolve(group: Group) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
padding: 80rpx 0;
|
padding: 80rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-text, .empty-text {
|
.loading-text, .empty-text {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-list {
|
.group-list {
|
||||||
padding: 0 40rpx;
|
padding: 0 $space-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-item {
|
.group-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 28rpx 32rpx;
|
padding: $font-lg $space-lg;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: $space-sm;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 28rpx;
|
border-radius: $radius-xl;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-icon {
|
.group-icon {
|
||||||
width: 80rpx;
|
width: 80rpx;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #FFF0E6;
|
background: $surface-warm;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -505,24 +527,24 @@ function handleDissolve(group: Group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-name {
|
.group-name {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-badge {
|
.role-badge {
|
||||||
padding: 2rpx 12rpx;
|
padding: 2rpx 12rpx;
|
||||||
border-radius: 8rpx;
|
border-radius: $radius-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-badge-text {
|
.role-badge-text {
|
||||||
font-size: 20rpx;
|
font-size: $font-xs;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-meta {
|
.group-meta {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4rpx;
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
@@ -532,19 +554,19 @@ function handleDissolve(group: Group) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 12rpx;
|
margin-top: 12rpx;
|
||||||
gap: 8rpx;
|
gap: $space-xs;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-label {
|
.invite-label {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-code {
|
.invite-code {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
font-family: 'Fredoka', monospace;
|
font-family: 'Fredoka', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,85 +575,91 @@ function handleDissolve(group: Group) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4rpx;
|
gap: 4rpx;
|
||||||
padding: 4rpx 12rpx;
|
padding: 4rpx 12rpx;
|
||||||
background: #FFF8F0;
|
background: $bg;
|
||||||
border-radius: 12rpx;
|
border-radius: $radius-sm;
|
||||||
|
|
||||||
&:active { background: #F0E0D6; }
|
&:active { background: $border; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-action-text {
|
.invite-action-text {
|
||||||
font-size: 22rpx;
|
font-size: $font-sm;
|
||||||
color: #8B7E7E;
|
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 {
|
.group-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8rpx;
|
gap: $space-xs;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-btn {
|
.group-btn {
|
||||||
padding: 12rpx 24rpx;
|
padding: 12rpx $space-md;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-md;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&.manage { background: #FFF0E6; }
|
&.manage { background: $surface-warm; }
|
||||||
&.leave { background: #FFF0E6; }
|
&.leave { background: $surface-warm; }
|
||||||
&.dissolve { background: #FFE8E8; }
|
&.dissolve { background: $danger-light; }
|
||||||
|
|
||||||
&:active { opacity: 0.7; }
|
&:active { opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-btn-text {
|
.group-btn-text {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&.manage-text { color: #FF8C69; }
|
&.manage-text { color: $primary; }
|
||||||
&.leave-text { color: #FF8C69; }
|
&.leave-text { color: $primary; }
|
||||||
&.dissolve-text { color: #FF6B6B; }
|
&.dissolve-text { color: $danger; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 成员管理弹窗 */
|
/* 成员管理弹窗 */
|
||||||
.modal-mask {
|
.modal-mask {
|
||||||
position: fixed;
|
@include modal-mask;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-modal {
|
.member-modal {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 40rpx 40rpx 0 0;
|
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 32rpx 40rpx 24rpx;
|
padding: $space-lg $space-xl $space-md;
|
||||||
border-bottom: 2rpx solid #F0E0D6;
|
border-bottom: 2rpx solid $border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close {
|
.modal-close {
|
||||||
@@ -641,14 +669,14 @@ function handleDissolve(group: Group) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #F0E0D6;
|
background: $border;
|
||||||
|
|
||||||
&:active { background: #E0D6D0; }
|
&:active { background: darken($border, 5%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close-text {
|
.modal-close-text {
|
||||||
font-size: 36rpx;
|
font-size: $font-2xl;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,19 +689,17 @@ function handleDissolve(group: Group) {
|
|||||||
|
|
||||||
.member-list {
|
.member-list {
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
padding: 16rpx 40rpx 40rpx;
|
padding: $space-sm $space-xl $space-xl;
|
||||||
box-sizing: border-box;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-item {
|
.member-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 20rpx 0;
|
padding: $space-lg 0;
|
||||||
gap: 20rpx;
|
gap: $space-lg;
|
||||||
border-bottom: 2rpx solid #F8F0EB;
|
border-bottom: 2rpx solid #F8F0EB;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
&:last-child { border-bottom: none; }
|
&:last-child { border-bottom: none; }
|
||||||
}
|
}
|
||||||
@@ -682,7 +708,7 @@ function handleDissolve(group: Group) {
|
|||||||
width: 72rpx;
|
width: 72rpx;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #F0E0D6;
|
background: $border;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,9 +718,9 @@ function handleDissolve(group: Group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-name {
|
.member-name {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -706,22 +732,22 @@ function handleDissolve(group: Group) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-role-text {
|
.member-role-text {
|
||||||
font-size: 22rpx;
|
font-size: $font-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-remove {
|
.member-remove {
|
||||||
padding: 12rpx 24rpx;
|
padding: 12rpx $space-md;
|
||||||
background: #FFE8E8;
|
background: $danger-light;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-md;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
&:active { opacity: 0.7; }
|
&:active { opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-remove-text {
|
.member-remove-text {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #FF6B6B;
|
color: $danger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
<text class="urgent-title">{{ urgentNotice.title }}</text>
|
<text class="urgent-title">{{ urgentNotice.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<scroll-view class="urgent-body" scroll-y>
|
<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>
|
<text class="urgent-content">{{ urgentNotice.content }}</text>
|
||||||
<view v-if="urgentNotice.link_url" class="urgent-link" @tap="openLink(urgentNotice.link_url)">
|
<view v-if="urgentNotice.link_url" class="urgent-link" @tap="openLink(urgentNotice.link_url)">
|
||||||
<text class="urgent-link-text">查看详情</text>
|
<text class="urgent-link-text">查看详情</text>
|
||||||
@@ -146,6 +146,9 @@ import { useGroupStore } from '@/stores/group'
|
|||||||
import { useNotificationStore } from '@/stores/notification'
|
import { useNotificationStore } from '@/stores/notification'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { getOverview } from '@/api/stats'
|
import { getOverview } from '@/api/stats'
|
||||||
|
import { syncRecurring } from '@/api/recurring'
|
||||||
|
import type { Transaction } from '@/api/transaction'
|
||||||
|
import { getNotificationImageUrl } from '@/api/notification'
|
||||||
import { formatAmount, formatAmountRaw } from '@/utils/format'
|
import { formatAmount, formatAmountRaw } from '@/utils/format'
|
||||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||||
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
|
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
|
||||||
@@ -164,7 +167,7 @@ const DEFAULT_BUDGET = 500000 // 5000元(分)
|
|||||||
|
|
||||||
const overview = computed(() => statsStore.overview)
|
const overview = computed(() => statsStore.overview)
|
||||||
const budget = computed(() => budgetStore.budget)
|
const budget = computed(() => budgetStore.budget)
|
||||||
const recentTx = ref<any[]>([])
|
const recentTx = ref<Transaction[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
const todayExpense = ref(0)
|
const todayExpense = ref(0)
|
||||||
@@ -203,26 +206,31 @@ function openLink(url: string) {
|
|||||||
|
|
||||||
async function loadData(silent = false) {
|
async function loadData(silent = false) {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!silent) loading.value = true
|
if (!silent) loading.value = true
|
||||||
loadError.value = false
|
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([
|
await Promise.all([
|
||||||
statsStore.fetchOverview(),
|
statsStore.fetchOverview(),
|
||||||
budgetStore.fetchBudget(),
|
txStore.fetchTransactions({ page: 1 })
|
||||||
txStore.fetchTransactions({ page: 1 }),
|
|
||||||
fetchTodayData(today)
|
|
||||||
])
|
])
|
||||||
recentTx.value = txStore.transactions.slice(0, 5)
|
recentTx.value = txStore.transactions.slice(0, 5)
|
||||||
|
|
||||||
// 非关键数据:失败不影响页面显示
|
// 第二级:次要数据(异步,不阻塞首屏)
|
||||||
|
Promise.all([
|
||||||
|
budgetStore.fetchBudget(),
|
||||||
|
fetchTodayData()
|
||||||
|
]).catch(() => {})
|
||||||
|
|
||||||
|
// 第三级:非关键数据
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
userStore.fetchUserInfo(),
|
userStore.fetchUserInfo(),
|
||||||
notifStore.fetchUnreadCount(),
|
notifStore.fetchUnreadCount(),
|
||||||
notifStore.fetchUrgentNotifications()
|
notifStore.fetchUrgentNotifications(),
|
||||||
])
|
syncRecurring().catch(() => {})
|
||||||
|
]).catch(() => {})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Load error:', e)
|
console.error('Load error:', e)
|
||||||
if (!silent) loadError.value = true
|
if (!silent) loadError.value = true
|
||||||
@@ -231,8 +239,10 @@ async function loadData(silent = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTodayData(today: string) {
|
async function fetchTodayData() {
|
||||||
try {
|
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 })
|
const data = await getOverview({ startDate: today, endDate: today, group_id: groupStore.currentGroupId })
|
||||||
todayExpense.value = data.expense || 0
|
todayExpense.value = data.expense || 0
|
||||||
todayIncome.value = data.income || 0
|
todayIncome.value = data.income || 0
|
||||||
@@ -280,38 +290,33 @@ function goBills() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
@include page-base;
|
||||||
background: #FFF8F0;
|
|
||||||
padding: 0 0 180rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
position: sticky;
|
@include sticky-header;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
background: #FFF8F0;
|
@include status-bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 40rpx 32rpx;
|
padding: $space-sm $space-xl $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-wrap {
|
.avatar-wrap {
|
||||||
width: 72rpx;
|
width: 72rpx;
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
background: #FFF0E6;
|
background: $surface-warm;
|
||||||
display: flex;
|
@include flex-center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,71 +327,63 @@ function goBills() {
|
|||||||
|
|
||||||
.greeting {
|
.greeting {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 36rpx;
|
font-size: $font-2xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
margin-left: 16rpx;
|
margin-left: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bell {
|
.bell {
|
||||||
width: 88rpx;
|
width: 88rpx;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
display: flex;
|
@include flex-center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:active { opacity: 0.6; }
|
&:active { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.bell-badge {
|
.bell-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8rpx;
|
top: $space-xs;
|
||||||
right: 8rpx;
|
right: $space-xs;
|
||||||
min-width: 32rpx;
|
min-width: 32rpx;
|
||||||
height: 32rpx;
|
height: 32rpx;
|
||||||
padding: 0 8rpx;
|
padding: 0 $space-xs;
|
||||||
background: #FF6B6B;
|
background: $danger;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-full;
|
||||||
display: flex;
|
@include flex-center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bell-badge-text {
|
.bell-badge-text {
|
||||||
font-size: 18rpx;
|
font-size: $font-xs;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: $surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-card {
|
.overview-card {
|
||||||
margin: 0 40rpx;
|
margin: 0 $space-xl;
|
||||||
padding: 48rpx;
|
padding: $space-2xl;
|
||||||
background: #FFFFFF;
|
@include card($radius-2xl);
|
||||||
border-radius: 40rpx;
|
box-shadow: $shadow-clay;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-label {
|
.card-label {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 12rpx;
|
margin-bottom: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-row {
|
.amount-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.currency {
|
.currency {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 44rpx;
|
font-size: $font-3xl;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
margin-right: 4rpx;
|
margin-right: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,195 +391,168 @@ function goBills() {
|
|||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 72rpx;
|
font-size: 72rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-stats {
|
.mini-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
margin-top: 40rpx;
|
margin-top: $space-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-stat {
|
.mini-stat {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 24rpx 12rpx;
|
padding: $space-md $space-sm;
|
||||||
border-radius: 24rpx;
|
border-radius: $radius-xl;
|
||||||
background: #FFF8F0;
|
background: $bg;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-label {
|
.mini-label {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-value {
|
.mini-value {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
overflow: hidden;
|
@include text-ellipsis;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&.income { color: #7BC67E; }
|
&.income { color: $success; }
|
||||||
&.expense { color: #FF6B6B; }
|
&.expense { color: $danger; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-card {
|
.today-card {
|
||||||
margin: 24rpx 40rpx 0;
|
margin: $space-md $space-xl 0;
|
||||||
padding: 28rpx 32rpx;
|
padding: $space-lg $space-lg;
|
||||||
background: #FFFFFF;
|
@include card($radius-xl);
|
||||||
border-radius: 28rpx;
|
|
||||||
border: 2rpx solid #F0E0D6;
|
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-item {
|
.today-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8rpx;
|
gap: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-label {
|
.today-label {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-amount {
|
.today-amount {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
&.expense { color: #FF6B6B; }
|
&.expense { color: $danger; }
|
||||||
&.income { color: #7BC67E; }
|
&.income { color: $success; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-divider {
|
.today-divider {
|
||||||
width: 2rpx;
|
width: 2rpx;
|
||||||
height: 56rpx;
|
height: 56rpx;
|
||||||
background: #F0E0D6;
|
background: $border;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-count {
|
.today-count {
|
||||||
font-size: 22rpx;
|
font-size: $font-sm;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-actions {
|
.quick-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
padding: 0 40rpx;
|
padding: 0 $space-xl;
|
||||||
margin-top: 40rpx;
|
margin-top: $space-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-btn {
|
.quick-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 144rpx;
|
height: 144rpx;
|
||||||
border-radius: 32rpx;
|
border-radius: $radius-2xl;
|
||||||
background: #FFFFFF;
|
@include card;
|
||||||
border: 2rpx solid #F0E0D6;
|
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
@include flex-center;
|
||||||
justify-content: center;
|
gap: $space-xs;
|
||||||
gap: 8rpx;
|
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
box-shadow: inset 2rpx 2rpx 6rpx rgba(45, 27, 27, 0.1);
|
box-shadow: $shadow-clay-pressed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-text { font-size: 24rpx; font-weight: 500; color: #2D1B1B; }
|
.quick-text { font-size: $font-md; font-weight: 500; color: $text; }
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
display: flex;
|
@include flex-between;
|
||||||
justify-content: space-between;
|
padding: $space-2xl $space-xl $space-md;
|
||||||
align-items: center;
|
|
||||||
padding: 48rpx 40rpx 24rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-more {
|
.section-more {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
padding: 16rpx 24rpx;
|
padding: $space-sm $space-md;
|
||||||
margin: -16rpx -24rpx;
|
margin: -$space-sm -#{$space-md};
|
||||||
|
|
||||||
&:active { opacity: 0.6; }
|
&:active { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.tx-list {
|
.tx-list {
|
||||||
padding: 0 40rpx;
|
padding: 0 $space-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-skeleton {
|
.amount-skeleton {
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-item {
|
.skeleton-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 28rpx 0;
|
padding: $space-lg 0;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-info {
|
.skeleton-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8rpx;
|
gap: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-box {
|
.state-box {
|
||||||
display: flex;
|
@include state-box;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 16rpx;
|
|
||||||
padding: 80rpx 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-text {
|
.state-text {
|
||||||
font-size: 28rpx;
|
@include state-text;
|
||||||
color: #BFB3B3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 强提醒公告弹窗 */
|
/* 强提醒公告弹窗 */
|
||||||
.modal-mask {
|
.modal-mask {
|
||||||
position: fixed;
|
@include modal-mask-high;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.urgent-modal {
|
.urgent-modal {
|
||||||
width: 80%;
|
width: 80%;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 32rpx;
|
border-radius: $radius-2xl;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -591,52 +561,52 @@ function goBills() {
|
|||||||
.urgent-header {
|
.urgent-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12rpx;
|
gap: $space-sm;
|
||||||
padding: 32rpx 32rpx 16rpx;
|
padding: $space-xl $space-lg $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.urgent-title {
|
.urgent-title {
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.urgent-body {
|
.urgent-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0 32rpx 24rpx;
|
padding: 0 $space-lg $space-lg;
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.urgent-image {
|
.urgent-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 16rpx;
|
border-radius: $radius-md;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.urgent-content {
|
.urgent-content {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.urgent-link {
|
.urgent-link {
|
||||||
margin-top: 16rpx;
|
margin-top: $space-sm;
|
||||||
padding: 12rpx 0;
|
padding: $space-xs 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.urgent-link-text {
|
.urgent-link-text {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.urgent-footer {
|
.urgent-footer {
|
||||||
padding: 24rpx 32rpx;
|
padding: $space-lg;
|
||||||
border-top: 2rpx solid #F0E0D6;
|
border-top: 2rpx solid $border;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
&:active { background: #FFF8F0; }
|
&:active { background: $surface-warm; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.urgent-btn-text {
|
.urgent-btn-text {
|
||||||
|
|||||||
@@ -39,24 +39,15 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="notif-content">
|
<view class="notif-content">
|
||||||
<view class="notif-title-row">
|
<view class="notif-title-row">
|
||||||
<text v-if="item.is_pinned" class="pin-badge">📌</text>
|
<text v-if="item.is_pinned" class="pin-badge">置顶</text>
|
||||||
<text class="notif-title">{{ item.title }}</text>
|
<text class="notif-title">{{ item.title }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
|
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
|
||||||
<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">
|
<view class="notif-meta">
|
||||||
<text class="notif-time">{{ formatTime(item.created_at) }}</text>
|
<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>
|
<text v-if="item.link_url" class="notif-link" @tap.stop="openLink(item.link_url)">查看详情</text>
|
||||||
</view>
|
</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>
|
||||||
<view v-if="!item.is_read" class="unread-dot"></view>
|
<view v-if="!item.is_read" class="unread-dot"></view>
|
||||||
</view>
|
</view>
|
||||||
@@ -65,23 +56,40 @@
|
|||||||
<text class="no-more-text">没有更多了</text>
|
<text class="no-more-text">没有更多了</text>
|
||||||
</view>
|
</view>
|
||||||
</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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
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 { useNotificationStore } from '@/stores/notification'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { getNotifications, markRead, markAllRead, getNotificationImageUrl } from '@/api/notification'
|
||||||
import { getNotifications, markRead, markAllRead, deleteNotification, updateNotification } from '@/api/notification'
|
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import type { Notification } from '@/api/notification'
|
import type { Notification } from '@/api/notification'
|
||||||
|
|
||||||
const notifStore = useNotificationStore()
|
const notifStore = useNotificationStore()
|
||||||
const userStore = useUserStore()
|
|
||||||
const isAdmin = computed(() => userStore.userInfo?.role === 'admin')
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'all', label: '全部' },
|
{ key: 'all', label: '全部' },
|
||||||
@@ -95,6 +103,8 @@ const list = ref<Notification[]>([])
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const detailItem = ref<Notification | null>(null)
|
||||||
|
const initialLoaded = ref(false)
|
||||||
let loadSeq = 0
|
let loadSeq = 0
|
||||||
|
|
||||||
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
|
const noMore = computed(() => list.value.length >= total.value && total.value > 0)
|
||||||
@@ -102,10 +112,16 @@ const noMore = computed(() => list.value.length >= total.value && total.value >
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
await loadNotifications(true)
|
await loadNotifications(true)
|
||||||
|
initialLoaded.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
|
if (!initialLoaded.value) return
|
||||||
notifStore.fetchUnreadCount()
|
notifStore.fetchUnreadCount()
|
||||||
|
// 返回页面时刷新列表(如全部已读后)
|
||||||
|
if (list.value.length > 0) {
|
||||||
|
loadNotifications(true)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function switchTab(key: string) {
|
function switchTab(key: string) {
|
||||||
@@ -171,6 +187,7 @@ function formatTime(dateStr: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleTap(item: Notification) {
|
async function handleTap(item: Notification) {
|
||||||
|
// 标记已读
|
||||||
if (!item.is_read) {
|
if (!item.is_read) {
|
||||||
try {
|
try {
|
||||||
await markRead(item.id)
|
await markRead(item.id)
|
||||||
@@ -180,6 +197,8 @@ async function handleTap(item: Notification) {
|
|||||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 显示详情
|
||||||
|
detailItem.value = item
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMarkAllRead() {
|
async function handleMarkAllRead() {
|
||||||
@@ -205,40 +224,15 @@ function openLink(url: string) {
|
|||||||
// #endif
|
// #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() {
|
function goBack() {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await loadNotifications(true)
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
onReachBottom(() => {
|
onReachBottom(() => {
|
||||||
if (noMore.value || loading.value) return
|
if (noMore.value || loading.value) return
|
||||||
page.value++
|
page.value++
|
||||||
@@ -247,75 +241,67 @@ onReachBottom(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
@include page-base;
|
||||||
background: #FFF8F0;
|
|
||||||
padding: 0 0 180rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
position: sticky;
|
@include sticky-header;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar { background: #FFF8F0; }
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
.nav-bar {
|
.nav-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 40rpx 24rpx;
|
padding: $space-sm $space-xl $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-back {
|
.nav-back {
|
||||||
width: 88rpx;
|
@include nav-back;
|
||||||
height: 88rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
&:active { opacity: 0.6; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-title {
|
.nav-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 36rpx;
|
font-size: $font-2xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-action {
|
.nav-action {
|
||||||
padding: 8rpx 16rpx;
|
padding: $space-xs $space-sm;
|
||||||
&:active { opacity: 0.6; }
|
&:active { opacity: 0.6; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-action-text {
|
.nav-action-text {
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
color: #FF8C69;
|
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 {
|
.tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
background: #FFF0E6;
|
background: $surface-warm;
|
||||||
border-radius: 24rpx;
|
border-radius: $space-md;
|
||||||
padding: 8rpx;
|
padding: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
padding: 12rpx 32rpx;
|
padding: 12rpx $space-lg;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
transition: all 0.2s;
|
transition: all $transition-normal;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
|
box-shadow: $shadow-sm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,31 +309,31 @@ onReachBottom(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
padding: 120rpx 0;
|
padding: 120rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-text, .empty-text {
|
.loading-text, .empty-text {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-list { padding: 0 40rpx; }
|
.notification-list { padding: 0 $space-xl; }
|
||||||
|
|
||||||
.notification-item {
|
.notification-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 20rpx;
|
gap: $space-lg;
|
||||||
padding: 28rpx 32rpx;
|
padding: $font-lg $space-lg;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: $space-sm;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 28rpx;
|
border-radius: $radius-xl;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&.unread { border-left: 6rpx solid #FF8C69; }
|
&.unread { border-left: 6rpx solid $primary; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-icon {
|
.notif-icon {
|
||||||
@@ -365,25 +351,30 @@ onReachBottom(() => {
|
|||||||
.notif-title-row {
|
.notif-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8rpx;
|
gap: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pin-badge {
|
.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 {
|
.notif-title {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-body {
|
.notif-body {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 8rpx;
|
margin-top: $space-xs;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -391,70 +382,137 @@ onReachBottom(() => {
|
|||||||
|
|
||||||
.notif-image {
|
.notif-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 12rpx;
|
border-radius: $radius-md;
|
||||||
margin-top: 12rpx;
|
margin-top: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-meta {
|
.notif-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 8rpx;
|
margin-top: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-time {
|
.notif-time {
|
||||||
font-size: 22rpx;
|
font-size: $font-sm;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unread-dot {
|
.unread-dot {
|
||||||
width: 16rpx;
|
width: $space-sm;
|
||||||
height: 16rpx;
|
height: $space-sm;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #FF8C69;
|
background: $primary;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 8rpx;
|
margin-top: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-link {
|
.notif-link {
|
||||||
font-size: 22rpx;
|
font-size: $font-sm;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pinned {
|
.pinned {
|
||||||
border-color: #FF8C69;
|
border-color: $primary;
|
||||||
background: #FFFAF7;
|
background: #FFFAF7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-more {
|
.no-more {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 32rpx 0;
|
padding: $space-lg 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-more-text {
|
.no-more-text {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #BFB3B3;
|
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;
|
display: flex;
|
||||||
gap: 12rpx;
|
flex-direction: column;
|
||||||
margin-top: 12rpx;
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-btn {
|
.detail-header {
|
||||||
padding: 8rpx 16rpx;
|
display: flex;
|
||||||
background: #FFF0E6;
|
align-items: center;
|
||||||
border-radius: 12rpx;
|
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; }
|
&:active { opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-btn-text {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #FF8C69;
|
|
||||||
&.danger-text { color: #FF6B6B; }
|
|
||||||
}
|
|
||||||
</style>
|
</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>
|
<text class="form-label">昵称</text>
|
||||||
<input class="form-input" v-model="formNickname" placeholder="请输入昵称" maxlength="50" />
|
<input class="form-input" v-model="formNickname" placeholder="请输入昵称" maxlength="50" />
|
||||||
</view>
|
</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>
|
||||||
|
|
||||||
<view class="tips-card">
|
<view class="tips-card">
|
||||||
<text class="tips-title">提示</text>
|
<text class="tips-title">提示</text>
|
||||||
<text class="tips-text">• 昵称最长 50 个字符</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>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -48,6 +54,7 @@ import Icon from '@/components/Icon/Icon.vue'
|
|||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const formNickname = ref('')
|
const formNickname = ref('')
|
||||||
|
const formSlogan = ref('')
|
||||||
const previewUrl = ref('')
|
const previewUrl = ref('')
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -55,6 +62,7 @@ const saving = ref(false)
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
formNickname.value = userStore.nickname
|
formNickname.value = userStore.nickname
|
||||||
|
formSlogan.value = userStore.slogan
|
||||||
previewUrl.value = userStore.avatarUrl
|
previewUrl.value = userStore.avatarUrl
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,7 +101,10 @@ async function saveProfile() {
|
|||||||
}
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await userStore.updateProfile(nickname)
|
await userStore.updateProfile({
|
||||||
|
nickname,
|
||||||
|
slogan: formSlogan.value.trim()
|
||||||
|
})
|
||||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
setTimeout(() => uni.navigateBack(), 1500)
|
setTimeout(() => uni.navigateBack(), 1500)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -105,43 +116,34 @@ async function saveProfile() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
@include page-base;
|
||||||
background: #FFF8F0;
|
|
||||||
padding: 0 0 180rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
position: sticky;
|
@include sticky-header;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar { background: #FFF8F0; }
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
.nav-bar {
|
.nav-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 40rpx 24rpx;
|
padding: $space-sm $space-xl $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-back {
|
.nav-back {
|
||||||
width: 88rpx;
|
@include nav-back;
|
||||||
height: 88rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&:active { opacity: 0.6; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-title {
|
.nav-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 36rpx;
|
font-size: $font-2xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-save {
|
.nav-save {
|
||||||
@@ -155,16 +157,16 @@ async function saveProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.save-text {
|
.save-text {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-section {
|
.avatar-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 48rpx 0 32rpx;
|
padding: $space-2xl 0 $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-wrap {
|
.avatar-wrap {
|
||||||
@@ -172,7 +174,7 @@ async function saveProfile() {
|
|||||||
height: 160rpx;
|
height: 160rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 6rpx solid rgba(255, 255, 255, 0.5);
|
border: 6rpx solid rgba(255, 255, 255, 0.5);
|
||||||
background: linear-gradient(135deg, #FF8C69, #FFB89A);
|
background: linear-gradient(135deg, $primary, #FFB89A);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -196,61 +198,67 @@ async function saveProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.avatar-hint {
|
.avatar-hint {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
margin-top: 16rpx;
|
margin-top: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-card {
|
.form-card {
|
||||||
margin: 32rpx 40rpx 0;
|
margin: $space-lg $space-xl 0;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 40rpx;
|
border-radius: $radius-2xl;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-item {
|
.form-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 32rpx 40rpx;
|
padding: $space-lg $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-divider {
|
||||||
|
height: 2rpx;
|
||||||
|
background: $border;
|
||||||
|
margin: 0 $space-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
width: 120rpx;
|
width: 120rpx;
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tips-card {
|
.tips-card {
|
||||||
margin: 32rpx 40rpx 0;
|
margin: $space-lg $space-xl 0;
|
||||||
padding: 32rpx 40rpx;
|
padding: $space-lg $space-xl;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 40rpx;
|
border-radius: $radius-2xl;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tips-title {
|
.tips-title {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 16rpx;
|
margin-bottom: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tips-text {
|
.tips-text {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: $space-xs;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="p-info">
|
<view class="p-info">
|
||||||
<text class="p-name">{{ userStore.nickname }}</text>
|
<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>
|
<text class="p-streak">累计 {{ txStore.total }} 笔记录</text>
|
||||||
</view>
|
</view>
|
||||||
<Icon name="chevronRight" :size="28" color="rgba(255,255,255,0.6)" />
|
<Icon name="chevronRight" :size="28" color="rgba(255,255,255,0.6)" />
|
||||||
@@ -51,17 +51,29 @@
|
|||||||
<text class="mi-label">分类管理</text>
|
<text class="mi-label">分类管理</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item" @tap="exportData">
|
<view class="menu-item" @tap="goRecurring">
|
||||||
|
<text class="mi-label">周期账单</text>
|
||||||
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
|
<view class="menu-item" @tap="openExportPanel">
|
||||||
<text class="mi-label">数据导出</text>
|
<text class="mi-label">数据导出</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="menu-card mt">
|
<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">
|
<view class="menu-item" @tap="showAbout = true">
|
||||||
<text class="mi-label">关于小菜</text>
|
<text class="mi-label">关于小菜</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
</view>
|
</view>
|
||||||
|
<view class="menu-item" @tap="goPrivacy">
|
||||||
|
<text class="mi-label">隐私政策</text>
|
||||||
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 管理员入口 -->
|
<!-- 管理员入口 -->
|
||||||
@@ -76,16 +88,70 @@
|
|||||||
<!-- 关于弹窗 -->
|
<!-- 关于弹窗 -->
|
||||||
<view class="modal-mask" v-if="showAbout" @tap="showAbout = false">
|
<view class="modal-mask" v-if="showAbout" @tap="showAbout = false">
|
||||||
<view class="modal" @tap.stop>
|
<view class="modal" @tap.stop>
|
||||||
<text class="about-title">小菜记账</text>
|
<text class="about-title">{{ configStore.appName }}</text>
|
||||||
<text class="about-version">v1.0.0</text>
|
<text class="about-version">v{{ configStore.config.app_version || '1.0.0' }}</text>
|
||||||
<text class="about-desc">温暖 x 轻松的个人记账小程序</text>
|
<text class="about-desc">{{ configStore.appSlogan }}</text>
|
||||||
<text class="about-desc">让记账从负担变成享受</text>
|
<text class="about-desc">{{ configStore.appDescription }}</text>
|
||||||
<view class="modal-btns modal-btns-top">
|
<view class="modal-btns modal-btns-top">
|
||||||
<view class="modal-btn confirm" @tap="showAbout = false">知道了</view>
|
<view class="modal-btn confirm" @tap="showAbout = false">知道了</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 导出弹窗 -->
|
||||||
|
<view class="modal-mask" v-if="showExportPanel" @tap="showExportPanel = false">
|
||||||
|
<view class="export-modal" @tap.stop>
|
||||||
|
<text class="modal-title">数据导出</text>
|
||||||
|
|
||||||
|
<!-- 日期范围 -->
|
||||||
|
<view class="export-section">
|
||||||
|
<text class="export-label">日期范围</text>
|
||||||
|
<view class="export-date-row">
|
||||||
|
<picker mode="date" :value="exportStartDate" :end="exportEndDate" @change="e => { exportStartDate = e.detail.value; updateExportCount() }">
|
||||||
|
<view class="export-date-picker">
|
||||||
|
<Icon name="calendar" :size="24" color="#8B7E7E" />
|
||||||
|
<text class="export-date-text">{{ exportStartDate }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<text class="export-date-sep">至</text>
|
||||||
|
<picker mode="date" :value="exportEndDate" :start="exportStartDate" :end="getLocalDateStr()" @change="e => { exportEndDate = e.detail.value; updateExportCount() }">
|
||||||
|
<view class="export-date-picker">
|
||||||
|
<Icon name="calendar" :size="24" color="#8B7E7E" />
|
||||||
|
<text class="export-date-text">{{ exportEndDate }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 类型筛选 -->
|
||||||
|
<view class="export-section">
|
||||||
|
<text class="export-label">交易类型</text>
|
||||||
|
<view class="export-tabs">
|
||||||
|
<view class="export-tab" :class="{ active: exportType === 'all' }" @tap="exportType = 'all'; updateExportCount()">全部</view>
|
||||||
|
<view class="export-tab" :class="{ active: exportType === 'expense' }" @tap="exportType = 'expense'; updateExportCount()">仅支出</view>
|
||||||
|
<view class="export-tab" :class="{ active: exportType === 'income' }" @tap="exportType = 'income'; updateExportCount()">仅收入</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 导出格式 -->
|
||||||
|
<view class="export-section">
|
||||||
|
<text class="export-label">导出格式</text>
|
||||||
|
<view class="export-tabs">
|
||||||
|
<view class="export-tab" :class="{ active: exportFormat === 'csv' }" @tap="exportFormat = 'csv'">CSV</view>
|
||||||
|
<view class="export-tab" :class="{ active: exportFormat === 'json' }" @tap="exportFormat = 'json'">JSON</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 预览 & 导出 -->
|
||||||
|
<view class="export-footer">
|
||||||
|
<text class="export-preview">共 <text class="export-count">{{ exportCount }}</text> 条记录</text>
|
||||||
|
<view class="export-btn" :class="{ disabled: exportCount === 0 || exportLoading }" @tap="doExport">
|
||||||
|
<text class="export-btn-text">{{ exportLoading ? '导出中...' : '导出 ' + exportFormat.toUpperCase() }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 身份选择弹窗 -->
|
<!-- 身份选择弹窗 -->
|
||||||
<view class="modal-mask" v-if="showIdentity" @tap="showIdentity = false">
|
<view class="modal-mask" v-if="showIdentity" @tap="showIdentity = false">
|
||||||
<view class="identity-modal" @tap.stop>
|
<view class="identity-modal" @tap.stop>
|
||||||
@@ -127,14 +193,16 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
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 { useTransactionStore } from '@/stores/transaction'
|
||||||
import { useStatsStore } from '@/stores/stats'
|
import { useStatsStore } from '@/stores/stats'
|
||||||
import { useBudgetStore } from '@/stores/budget'
|
import { useBudgetStore } from '@/stores/budget'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { getTransactions } from '@/api/transaction'
|
import { getTransactions } from '@/api/transaction'
|
||||||
|
import type { Transaction } from '@/api/transaction'
|
||||||
import { formatAmount } from '@/utils/format'
|
import { formatAmount } from '@/utils/format'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight } from '@/utils/system'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
@@ -144,6 +212,7 @@ const statsStore = useStatsStore()
|
|||||||
const budgetStore = useBudgetStore()
|
const budgetStore = useBudgetStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
const overview = computed(() => statsStore.overview)
|
const overview = computed(() => statsStore.overview)
|
||||||
const budget = computed(() => budgetStore.budget)
|
const budget = computed(() => budgetStore.budget)
|
||||||
@@ -152,6 +221,35 @@ const showAbout = ref(false)
|
|||||||
const showIdentity = ref(false)
|
const showIdentity = ref(false)
|
||||||
const initialLoaded = ref(false)
|
const initialLoaded = ref(false)
|
||||||
|
|
||||||
|
// 导出面板状态
|
||||||
|
const showExportPanel = ref(false)
|
||||||
|
const exportStartDate = ref(getLocalDateStr().replace(/-/g, '-').replace(/(\d+)$/, '01')) // 本月1日
|
||||||
|
const exportEndDate = ref(getLocalDateStr())
|
||||||
|
const exportType = ref<'all' | 'expense' | 'income'>('all')
|
||||||
|
const exportFormat = ref<'csv' | 'json'>('csv')
|
||||||
|
const exportCount = ref(0)
|
||||||
|
const exportLoading = ref(false)
|
||||||
|
|
||||||
|
function openExportPanel() {
|
||||||
|
showExportPanel.value = true
|
||||||
|
updateExportCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateExportCount() {
|
||||||
|
try {
|
||||||
|
const data = await getTransactions({
|
||||||
|
page: 1, pageSize: 1,
|
||||||
|
startDate: exportStartDate.value,
|
||||||
|
endDate: exportEndDate.value,
|
||||||
|
...(exportType.value !== 'all' ? { type: exportType.value } : {}),
|
||||||
|
group_id: groupStore.currentGroupId
|
||||||
|
})
|
||||||
|
exportCount.value = data.total
|
||||||
|
} catch {
|
||||||
|
exportCount.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -185,6 +283,20 @@ onShow(async () => {
|
|||||||
} catch {}
|
} 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() {
|
function goBudget() {
|
||||||
uni.navigateTo({ url: '/pages/budget/index' })
|
uni.navigateTo({ url: '/pages/budget/index' })
|
||||||
}
|
}
|
||||||
@@ -193,6 +305,18 @@ function goCategoryManage() {
|
|||||||
uni.navigateTo({ url: '/pages/category-manage/index' })
|
uni.navigateTo({ url: '/pages/category-manage/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goRecurring() {
|
||||||
|
uni.navigateTo({ url: '/pages/recurring/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPrivacy() {
|
||||||
|
uni.navigateTo({ url: '/pages/privacy/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goFeedback() {
|
||||||
|
uni.navigateTo({ url: '/pages/feedback/index' })
|
||||||
|
}
|
||||||
|
|
||||||
function goProfileEdit() {
|
function goProfileEdit() {
|
||||||
uni.navigateTo({ url: '/pages/profile-edit/index' })
|
uni.navigateTo({ url: '/pages/profile-edit/index' })
|
||||||
}
|
}
|
||||||
@@ -290,18 +414,30 @@ function getLocalDateStr(): string {
|
|||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportData() {
|
async function doExport() {
|
||||||
uni.showLoading({ title: '导出中...' })
|
if (exportCount.value === 0 || exportLoading.value) return
|
||||||
|
exportLoading.value = true
|
||||||
|
uni.showLoading({ title: '导出中 0%' })
|
||||||
try {
|
try {
|
||||||
// 分页获取全部数据(服务端 pageSize 上限 100)
|
// 分页获取筛选后的数据
|
||||||
const allList: any[] = []
|
const allList: Transaction[] = []
|
||||||
let page = 1
|
let page = 1
|
||||||
let total = 0
|
let total = 0
|
||||||
|
const baseParams: Record<string, any> = {
|
||||||
|
pageSize: 100,
|
||||||
|
startDate: exportStartDate.value,
|
||||||
|
endDate: exportEndDate.value,
|
||||||
|
group_id: groupStore.currentGroupId
|
||||||
|
}
|
||||||
|
if (exportType.value !== 'all') baseParams.type = exportType.value
|
||||||
|
|
||||||
do {
|
do {
|
||||||
const data = await getTransactions({ page, pageSize: 100, group_id: groupStore.currentGroupId })
|
const data = await getTransactions({ page, ...baseParams } as any)
|
||||||
allList.push(...data.list)
|
allList.push(...data.list)
|
||||||
total = data.total
|
total = data.total
|
||||||
page++
|
page++
|
||||||
|
const progress = Math.min(100, Math.round((allList.length / total) * 100))
|
||||||
|
uni.showLoading({ title: `导出中 ${progress}%` })
|
||||||
} while (allList.length < total)
|
} while (allList.length < total)
|
||||||
|
|
||||||
if (allList.length === 0) {
|
if (allList.length === 0) {
|
||||||
@@ -310,6 +446,18 @@ async function exportData() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exportFormat.value === 'json') {
|
||||||
|
const json = JSON.stringify(allList.map(t => ({
|
||||||
|
date: t.date,
|
||||||
|
type: t.type === 'expense' ? '支出' : '收入',
|
||||||
|
category: t.category_name || '未分类',
|
||||||
|
amount: (t.amount / 100).toFixed(2),
|
||||||
|
note: t.note || ''
|
||||||
|
})), null, 2)
|
||||||
|
|
||||||
|
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.json`
|
||||||
|
saveFile(json, fileName, 'application/json')
|
||||||
|
} else {
|
||||||
function csvEscape(val: string): string {
|
function csvEscape(val: string): string {
|
||||||
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
|
||||||
return '"' + val.replace(/"/g, '""') + '"'
|
return '"' + val.replace(/"/g, '""') + '"'
|
||||||
@@ -324,13 +472,25 @@ async function exportData() {
|
|||||||
}).join('\n')
|
}).join('\n')
|
||||||
|
|
||||||
const csv = '' + header + rows
|
const csv = '' + header + rows
|
||||||
|
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.csv`
|
||||||
|
saveFile(csv, fileName, 'text/csv')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '导出失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
exportLoading.value = false
|
||||||
|
showExportPanel.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFile(content: string, fileName: string, mimeType: string) {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
const blob = new Blob([content], { type: `${mimeType};charset=utf-8;` })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `小菜记账_${getLocalDateStr()}.csv`
|
a.download = fileName
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
@@ -339,59 +499,53 @@ async function exportData() {
|
|||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
const fs = uni.getFileSystemManager()
|
const fs = uni.getFileSystemManager()
|
||||||
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${getLocalDateStr()}.csv`
|
const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`
|
||||||
fs.writeFileSync(filePath, csv, 'utf-8')
|
fs.writeFileSync(filePath, content, 'utf-8')
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
|
const ext = mimeType === 'application/json' ? 'json' : 'csv'
|
||||||
uni.openDocument({
|
uni.openDocument({
|
||||||
filePath,
|
filePath,
|
||||||
fileType: 'csv',
|
fileType: ext,
|
||||||
success: () => {},
|
success: () => {},
|
||||||
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
|
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
|
||||||
})
|
})
|
||||||
// #endif
|
// #endif
|
||||||
} catch {
|
|
||||||
uni.hideLoading()
|
|
||||||
uni.showToast({ title: '导出失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
@include page-base;
|
||||||
background: #FFF8F0;
|
|
||||||
padding: 0 0 180rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
position: sticky;
|
@include sticky-header;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
background: #FFF8F0;
|
@include status-bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 36rpx;
|
font-size: $font-2xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
padding: 16rpx 0 24rpx;
|
padding: $space-sm 0 $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card {
|
.profile-card {
|
||||||
margin: 0 40rpx;
|
margin: 0 $space-xl;
|
||||||
padding: 48rpx;
|
padding: $space-2xl;
|
||||||
background: linear-gradient(135deg, #FF8C69, #FFB89A);
|
background: linear-gradient(135deg, $primary, #FFB89A);
|
||||||
border-radius: 40rpx;
|
border-radius: $radius-2xl;
|
||||||
box-shadow: 8rpx 8rpx 24rpx rgba(45, 27, 27, 0.1);
|
box-shadow: 8rpx 8rpx 24rpx rgba(45, 27, 27, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 32rpx;
|
gap: $space-lg;
|
||||||
|
|
||||||
&:active { opacity: 0.85; }
|
&:active { opacity: 0.85; }
|
||||||
}
|
}
|
||||||
@@ -408,72 +562,65 @@ async function exportData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.p-name {
|
.p-name {
|
||||||
font-size: 40rpx;
|
font-size: $font-3xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: $surface;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-tagline {
|
.p-tagline {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4rpx;
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-streak {
|
.p-streak {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: rgba(255, 255, 255, 0.65);
|
color: rgba(255, 255, 255, 0.65);
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 16rpx;
|
margin-top: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-stats {
|
.quick-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
padding: 0 40rpx;
|
padding: 0 $space-xl;
|
||||||
margin-top: 32rpx;
|
margin-top: $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qs-card {
|
.qs-card {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 32rpx 24rpx;
|
padding: $space-lg $space-md;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 32rpx;
|
border-radius: 32rpx;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qs-label {
|
.qs-label {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qs-value {
|
.qs-value {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
|
|
||||||
&.income { color: #7BC67E; }
|
&.income { color: $success; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.qs-skeleton {
|
.qs-skeleton {
|
||||||
width: 160rpx;
|
width: 160rpx;
|
||||||
height: 40rpx;
|
height: 40rpx;
|
||||||
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
|
@include shimmer;
|
||||||
background-size: 200% 100%;
|
border-radius: $radius-xs;
|
||||||
border-radius: 8rpx;
|
|
||||||
margin: 0 auto;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -483,98 +630,92 @@ async function exportData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-card {
|
.menu-card {
|
||||||
margin: 40rpx 40rpx 0;
|
margin: $space-xl $space-xl 0;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 40rpx;
|
border-radius: $radius-2xl;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&.mt { margin-top: 32rpx; }
|
&.mt { margin-top: $space-lg; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
height: 112rpx;
|
height: 112rpx;
|
||||||
padding: 0 40rpx;
|
padding: 0 $space-xl;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1rpx solid #F0E0D6;
|
border-bottom: 1rpx solid $border;
|
||||||
|
|
||||||
&:last-child { border-bottom: none; }
|
&:last-child { border-bottom: none; }
|
||||||
&:active { background: #FFF8F0; }
|
&:active { background: $bg; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.mi-label { flex: 1; font-size: 28rpx; font-weight: 500; color: #2D1B1B; }
|
.mi-label { flex: 1; font-size: $font-lg; font-weight: 500; color: $text; }
|
||||||
.mi-extra { font-size: 28rpx; color: #FF8C69; font-weight: 600; margin-right: 16rpx; }
|
.mi-extra { font-size: $font-lg; color: $primary; font-weight: 600; margin-right: $space-sm; }
|
||||||
|
|
||||||
.admin-entry {
|
.admin-entry {
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
.mi-label { color: #FF8C69; font-weight: 600; }
|
.mi-label { color: $primary; font-weight: 600; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-title {
|
.about-title {
|
||||||
font-size: 40rpx;
|
font-size: $font-3xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-version {
|
.about-version {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 8rpx 0 24rpx;
|
margin: $space-xs 0 $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-desc {
|
.about-desc {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-mask {
|
.modal-mask {
|
||||||
position: fixed;
|
@include modal-mask-high;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
width: 600rpx;
|
width: 600rpx;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 32rpx;
|
border-radius: $space-lg;
|
||||||
padding: 48rpx;
|
padding: $space-2xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-btns {
|
.modal-btns {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-btns-top {
|
.modal-btns-top {
|
||||||
margin-top: 40rpx;
|
margin-top: $space-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-btn {
|
.modal-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: $space-md;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
&.confirm {
|
&.confirm {
|
||||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
color: #FFFFFF;
|
color: $surface;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,17 +726,17 @@ async function exportData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.identity-card {
|
.identity-card {
|
||||||
margin: 24rpx 40rpx 0;
|
margin: $space-md $space-xl 0;
|
||||||
padding: 28rpx 32rpx;
|
padding: $font-lg $space-lg;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 28rpx;
|
border-radius: $radius-xl;
|
||||||
border: 2rpx solid #F0E0D6;
|
border: 2rpx solid $border;
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
box-shadow: $shadow-md;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20rpx;
|
gap: $space-lg;
|
||||||
|
|
||||||
&:active { background: #FFF0E6; }
|
&:active { background: $surface-warm; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-info {
|
.id-info {
|
||||||
@@ -605,14 +746,14 @@ async function exportData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.id-name {
|
.id-name {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-desc {
|
.id-desc {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
margin-top: 4rpx;
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,33 +762,33 @@ async function exportData() {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
border-radius: 40rpx 40rpx 0 0;
|
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||||
padding: 40rpx;
|
padding: $space-xl;
|
||||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
padding-bottom: calc($space-xl + env(safe-area-inset-bottom));
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 32rpx;
|
margin-bottom: $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-option {
|
.id-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20rpx;
|
gap: $space-lg;
|
||||||
padding: 28rpx 24rpx;
|
padding: $font-lg $space-md;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
margin-bottom: 12rpx;
|
margin-bottom: 12rpx;
|
||||||
|
|
||||||
&.active { background: #FFF0E6; }
|
&.active { background: $surface-warm; }
|
||||||
&:active { background: #FFF8F0; }
|
&:active { background: $bg; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-option-info {
|
.id-option-info {
|
||||||
@@ -657,53 +798,171 @@ async function exportData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.id-option-name {
|
.id-option-name {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-option-desc {
|
.id-option-desc {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #BFB3B3;
|
color: $text-muted;
|
||||||
margin-top: 2rpx;
|
margin-top: 2rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-actions {
|
.id-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
margin-top: 24rpx;
|
margin-top: $space-md;
|
||||||
padding-top: 24rpx;
|
padding-top: $space-md;
|
||||||
border-top: 1rpx solid #F0E0D6;
|
border-top: 1rpx solid $border;
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-action-btn {
|
.id-action-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 80rpx;
|
height: 80rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-lg;
|
||||||
background: #FFF0E6;
|
background: $surface-warm;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:active { background: #FFE8D6; }
|
&:active { background: darken($surface-warm, 3%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-action-text {
|
.id-action-text {
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-manage {
|
.id-manage {
|
||||||
margin-top: 16rpx;
|
margin-top: $space-sm;
|
||||||
padding: 24rpx 20rpx;
|
padding: $space-md $space-lg;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
&:active { background: #FFF0E6; border-radius: 16rpx; }
|
&:active { background: $surface-warm; border-radius: $radius-md; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.id-manage-text {
|
.id-manage-text {
|
||||||
font-size: 26rpx;
|
font-size: $font-base;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导出弹窗 */
|
||||||
|
.export-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||||
|
padding: $space-xl;
|
||||||
|
padding-bottom: calc($space-xl + env(safe-area-inset-bottom));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-section {
|
||||||
|
margin-bottom: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-label {
|
||||||
|
font-size: $font-base;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-picker {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-xs;
|
||||||
|
height: 72rpx;
|
||||||
|
padding: 0 $space-md;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-sep {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-muted;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-tab {
|
||||||
|
padding: $space-sm $space-md;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
background: $bg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
transition: all $transition-normal;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $primary;
|
||||||
|
color: $surface;
|
||||||
|
border-color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: $space-md;
|
||||||
|
border-top: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-preview {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text-sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-count {
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn {
|
||||||
|
height: 80rpx;
|
||||||
|
padding: 0 $space-xl;
|
||||||
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $space-xs;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $surface;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
515
client/src/pages/recurring/index.vue
Normal file
515
client/src/pages/recurring/index.vue
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="header-fixed">
|
||||||
|
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||||
|
<view class="nav-bar" :style="{ paddingRight: capsuleRight + 'px' }">
|
||||||
|
<view class="nav-back" @tap="goBack">
|
||||||
|
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
|
||||||
|
</view>
|
||||||
|
<text class="nav-title">周期账单</text>
|
||||||
|
<view class="nav-action" @tap="openAddModal">
|
||||||
|
<Icon name="plus" :size="40" color="#FF8C69" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 同步提示 -->
|
||||||
|
<view class="sync-bar" v-if="syncCount > 0" @tap="syncCount = 0">
|
||||||
|
<Icon name="check" :size="28" color="#43A047" />
|
||||||
|
<text class="sync-text">已自动生成 {{ syncCount }} 笔记录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载/空/列表 -->
|
||||||
|
<view v-if="loading" class="loading-box">
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="list.length === 0" class="empty-box">
|
||||||
|
<Icon name="edit" :size="48" color="#BFB3B3" />
|
||||||
|
<text class="empty-text">暂无周期账单</text>
|
||||||
|
<text class="empty-hint">设置房租、订阅、工资等定期收支</text>
|
||||||
|
<view class="empty-btn" @tap="openAddModal">
|
||||||
|
<text class="empty-btn-text">添加周期账单</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view v-else class="list" scroll-y>
|
||||||
|
<view v-for="item in list" :key="item.id" class="template-card" :class="{ inactive: !item.is_active }">
|
||||||
|
<view class="card-main" @tap="openEditModal(item)">
|
||||||
|
<CategoryIcon :label="(item.category_name || '?')[0]" :color="item.category_color || '#FF8C69'" :bg-color="(item.category_color || '#FF8C69') + '15'" size="md" />
|
||||||
|
<view class="card-info">
|
||||||
|
<view class="card-title-row">
|
||||||
|
<text class="card-name">{{ item.category_name || '未分类' }}</text>
|
||||||
|
<text class="card-frequency">{{ freqMap[item.frequency] }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="card-note" v-if="item.note">{{ item.note }}</text>
|
||||||
|
<text class="card-date">下次:{{ item.next_date }} {{ item.end_date ? '· 至 ' + item.end_date : '' }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="card-amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-actions">
|
||||||
|
<view class="card-switch" @tap="toggleActive(item)">
|
||||||
|
<view class="switch-track" :class="{ on: item.is_active }">
|
||||||
|
<view class="switch-thumb"></view>
|
||||||
|
</view>
|
||||||
|
<text class="switch-label">{{ item.is_active ? '开启' : '暂停' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-btn" @tap="openEditModal(item)">
|
||||||
|
<Icon name="edit" :size="24" color="#8B7E7E" />
|
||||||
|
</view>
|
||||||
|
<view class="card-btn" @tap="handleDelete(item)">
|
||||||
|
<Icon name="trash" :size="24" color="#FF6B6B" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 添加/编辑弹窗 -->
|
||||||
|
<view class="modal-mask" v-if="showModal" @tap="closeModal">
|
||||||
|
<view class="form-modal" @tap.stop>
|
||||||
|
<view class="modal-header">
|
||||||
|
<text class="modal-title">{{ editingId ? '编辑周期账单' : '添加周期账单' }}</text>
|
||||||
|
<view class="modal-close" @tap="closeModal">
|
||||||
|
<text class="modal-close-text">×</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<scroll-view class="modal-body" scroll-y>
|
||||||
|
<!-- 类型切换 -->
|
||||||
|
<view class="type-toggle-wrap">
|
||||||
|
<view class="type-toggle" :class="{ income: form.type === 'income' }">
|
||||||
|
<view class="slider"></view>
|
||||||
|
<view class="tab" :class="{ active: form.type === 'expense' }" @tap="form.type = 'expense'">支出</view>
|
||||||
|
<view class="tab" :class="{ active: form.type === 'income' }" @tap="form.type = 'income'">收入</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 金额 -->
|
||||||
|
<AmountEditor
|
||||||
|
ref="amountEditorRef"
|
||||||
|
v-model="amountStr"
|
||||||
|
:fixed="false"
|
||||||
|
size="medium"
|
||||||
|
:auto-focus="showModal"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 分类 -->
|
||||||
|
<scroll-view class="category-scroll" scroll-x @tap="blurAmountEditor">
|
||||||
|
<view class="category-row">
|
||||||
|
<view v-for="cat in currentCategories" :key="cat.id" class="cat-item" @tap="form.category_id = cat.id">
|
||||||
|
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" :selected="form.category_id === cat.id" />
|
||||||
|
<text class="cat-name" :class="{ active: form.category_id === cat.id }">{{ cat.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 备注 -->
|
||||||
|
<view class="form-item">
|
||||||
|
<Icon name="edit" :size="28" color="#8B7E7E" />
|
||||||
|
<input class="form-input" v-model="form.note" placeholder="备注(可选)" maxlength="50" @focus="blurAmountEditor" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 周期 -->
|
||||||
|
<view class="form-item">
|
||||||
|
<Icon name="calendar" :size="28" color="#8B7E7E" />
|
||||||
|
<view class="freq-options">
|
||||||
|
<view v-for="f in frequencies" :key="f.value" class="freq-tab" :class="{ active: form.frequency === f.value }" @tap="selectFrequency(f.value)">
|
||||||
|
<text class="freq-text">{{ f.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 开始日期 -->
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">开始日期</text>
|
||||||
|
<picker mode="date" :value="form.next_date" @change="e => form.next_date = e.detail.value">
|
||||||
|
<view class="form-picker">
|
||||||
|
<text class="form-picker-text">{{ form.next_date }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 结束日期 -->
|
||||||
|
<view class="form-item">
|
||||||
|
<text class="form-label">结束日期</text>
|
||||||
|
<picker mode="date" :value="form.end_date || ''" :start="form.next_date" @change="e => form.end_date = e.detail.value">
|
||||||
|
<view class="form-picker">
|
||||||
|
<text class="form-picker-text" :class="{ placeholder: !form.end_date }">{{ form.end_date || '永久(不自动结束)' }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<view class="modal-footer" @tap="handleSubmit">
|
||||||
|
<text class="submit-text">{{ editingId ? '保存修改' : '添加' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
|
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||||
|
import { formatAmount } from '@/utils/format'
|
||||||
|
import { useCategoryStore } from '@/stores/category'
|
||||||
|
import { getRecurringTemplates, createRecurringTemplate, updateRecurringTemplate, deleteRecurringTemplate, syncRecurring } from '@/api/recurring'
|
||||||
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||||||
|
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
|
||||||
|
import type { RecurringTemplate } from '@/api/recurring'
|
||||||
|
|
||||||
|
const catStore = useCategoryStore()
|
||||||
|
|
||||||
|
const list = ref<RecurringTemplate[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const amountStr = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
const syncCount = ref(0)
|
||||||
|
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
|
||||||
|
|
||||||
|
const freqMap: Record<string, string> = {
|
||||||
|
weekly: '每周', monthly: '每月', yearly: '每年'
|
||||||
|
}
|
||||||
|
const frequencies = [
|
||||||
|
{ value: 'monthly' as const, label: '每月' },
|
||||||
|
{ value: 'weekly' as const, label: '每周' },
|
||||||
|
{ value: 'yearly' as const, label: '每年' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultForm = {
|
||||||
|
type: 'expense' as 'expense' | 'income',
|
||||||
|
category_id: 0,
|
||||||
|
note: '',
|
||||||
|
frequency: 'monthly' as 'weekly' | 'monthly' | 'yearly',
|
||||||
|
next_date: getTodayStr(),
|
||||||
|
end_date: '' as string,
|
||||||
|
}
|
||||||
|
const form = ref({ ...defaultForm })
|
||||||
|
|
||||||
|
const currentCategories = computed(() => catStore.getByType(form.value.type))
|
||||||
|
|
||||||
|
function getTodayStr(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await waitForReady()
|
||||||
|
await Promise.all([loadList(), syncAndLoad()])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadList() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
list.value = await getRecurringTemplates()
|
||||||
|
} catch { } finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAndLoad() {
|
||||||
|
try {
|
||||||
|
// 同步到期账单(静默,失败不影响主流程)
|
||||||
|
const result = await syncRecurring()
|
||||||
|
if (result.generated > 0) {
|
||||||
|
syncCount.value = result.generated
|
||||||
|
setTimeout(() => { syncCount.value = 0 }, 5000)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddModal() {
|
||||||
|
editingId.value = null
|
||||||
|
form.value = { ...defaultForm, next_date: getTodayStr() }
|
||||||
|
const cats = catStore.getByType('expense')
|
||||||
|
if (cats.length > 0) form.value.category_id = cats[0].id
|
||||||
|
amountStr.value = ''
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(item: RecurringTemplate) {
|
||||||
|
editingId.value = item.id
|
||||||
|
form.value = {
|
||||||
|
type: item.type,
|
||||||
|
category_id: item.category_id,
|
||||||
|
note: item.note,
|
||||||
|
frequency: item.frequency,
|
||||||
|
next_date: item.next_date,
|
||||||
|
end_date: item.end_date || '',
|
||||||
|
}
|
||||||
|
amountStr.value = (item.amount / 100).toString()
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(item: RecurringTemplate) {
|
||||||
|
const newState = item.is_active ? 0 : 1
|
||||||
|
try {
|
||||||
|
await updateRecurringTemplate(item.id, { is_active: newState })
|
||||||
|
item.is_active = newState
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (saving.value) return
|
||||||
|
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
|
||||||
|
if (isNaN(amount) || amount <= 0) {
|
||||||
|
uni.showToast({ title: '请输入金额', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.category_id) {
|
||||||
|
uni.showToast({ title: '请选择分类', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
amount,
|
||||||
|
type: form.value.type,
|
||||||
|
category_id: form.value.category_id,
|
||||||
|
note: form.value.note,
|
||||||
|
frequency: form.value.frequency,
|
||||||
|
next_date: form.value.next_date,
|
||||||
|
end_date: form.value.end_date || undefined,
|
||||||
|
}
|
||||||
|
if (editingId.value) {
|
||||||
|
await updateRecurringTemplate(editingId.value, data)
|
||||||
|
} else {
|
||||||
|
await createRecurringTemplate(data)
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
await loadList()
|
||||||
|
uni.showToast({ title: editingId.value ? '已更新' : '已添加', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: RecurringTemplate) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除周期账单',
|
||||||
|
content: `确定删除「${item.category_name || ''} ${formatAmount(item.amount)}」?已生成的记录不受影响。`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await deleteRecurringTemplate(item.id)
|
||||||
|
list.value = list.value.filter(t => t.id !== item.id)
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
amountEditorRef.value?.blur()
|
||||||
|
showModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurAmountEditor() {
|
||||||
|
amountEditorRef.value?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFrequency(value: 'weekly' | 'monthly' | 'yearly') {
|
||||||
|
blurAmountEditor()
|
||||||
|
form.value.frequency = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() { uni.navigateBack() }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.page { min-height: 100vh; background: $bg; padding-bottom: 120rpx; }
|
||||||
|
.header-fixed { @include sticky-header; }
|
||||||
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: $space-sm $space-xl $space-md;
|
||||||
|
}
|
||||||
|
.nav-back { @include nav-back; }
|
||||||
|
.nav-title {
|
||||||
|
flex: 1; text-align: center;
|
||||||
|
font-size: $font-2xl; font-weight: 600; color: $text;
|
||||||
|
}
|
||||||
|
.nav-action {
|
||||||
|
width: 88rpx; height: 88rpx; @include flex-center;
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-bar {
|
||||||
|
display: flex; align-items: center; gap: $space-xs;
|
||||||
|
margin: $space-sm $space-xl; padding: $space-sm $space-md;
|
||||||
|
background: #E8F5E9; border-radius: $radius-lg;
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
.sync-text { font-size: $font-md; color: #43A047; font-weight: 500; }
|
||||||
|
|
||||||
|
.loading-box, .empty-box {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
padding: 120rpx 0; gap: $space-sm;
|
||||||
|
}
|
||||||
|
.loading-text, .empty-text { font-size: $font-lg; color: $text-muted; }
|
||||||
|
.empty-hint { font-size: $font-md; color: $text-muted; margin-top: -$space-xs; }
|
||||||
|
.empty-btn {
|
||||||
|
margin-top: $space-lg; padding: $space-sm $space-xl;
|
||||||
|
background: $primary; border-radius: $radius-lg;
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
.empty-btn-text { font-size: $font-base; color: $surface; font-weight: 600; }
|
||||||
|
|
||||||
|
.list { padding: 0 $space-xl; max-height: calc(100vh - 200rpx); }
|
||||||
|
|
||||||
|
.template-card {
|
||||||
|
background: $surface; border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border; box-shadow: $shadow-md;
|
||||||
|
padding: $space-lg; margin-bottom: $space-sm;
|
||||||
|
&.inactive { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-main {
|
||||||
|
display: flex; align-items: center; gap: $space-md;
|
||||||
|
&:active { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info { flex: 1; min-width: 0; }
|
||||||
|
.card-title-row { display: flex; align-items: center; gap: $space-sm; }
|
||||||
|
.card-name { font-size: $font-lg; font-weight: 600; color: $text; }
|
||||||
|
.card-frequency {
|
||||||
|
font-size: $font-xs; color: $primary; background: $primary-light;
|
||||||
|
padding: 2rpx 10rpx; border-radius: $radius-xs;
|
||||||
|
}
|
||||||
|
.card-note {
|
||||||
|
font-size: $font-sm; color: $text-sec;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
margin-top: 2rpx;
|
||||||
|
}
|
||||||
|
.card-date { font-size: $font-sm; color: $text-muted; margin-top: 2rpx; display: block; }
|
||||||
|
.card-amount {
|
||||||
|
font-family: 'Fredoka', sans-serif; font-size: $font-xl; font-weight: 600;
|
||||||
|
&.expense { color: $text; } &.income { color: $success; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex; align-items: center; gap: $space-md;
|
||||||
|
margin-top: $space-sm; padding-top: $space-sm;
|
||||||
|
border-top: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
.card-switch {
|
||||||
|
display: flex; align-items: center; gap: $space-sm;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.switch-track {
|
||||||
|
width: 44rpx; height: 24rpx; border-radius: 12rpx;
|
||||||
|
background: #D0C4C4; position: relative; transition: background $transition-normal;
|
||||||
|
&.on { background: $primary; }
|
||||||
|
}
|
||||||
|
.switch-thumb {
|
||||||
|
position: absolute; top: 2rpx; left: 2rpx;
|
||||||
|
width: 20rpx; height: 20rpx; border-radius: 50%;
|
||||||
|
background: $surface; transition: transform $transition-normal;
|
||||||
|
.on & { transform: translateX(20rpx); }
|
||||||
|
}
|
||||||
|
.switch-label { font-size: $font-sm; color: $text-muted; }
|
||||||
|
.card-btn {
|
||||||
|
width: 64rpx; height: 64rpx; @include flex-center; border-radius: 50%;
|
||||||
|
&:active { background: $bg; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单弹窗 */
|
||||||
|
.modal-mask { @include modal-mask; }
|
||||||
|
.form-modal {
|
||||||
|
width: 100%; max-height: 90vh; background: $surface;
|
||||||
|
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||||
|
display: flex; flex-direction: column; margin-top: auto;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: $space-lg $space-xl $space-md; border-bottom: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
.modal-title { font-size: $font-xl; font-weight: 600; color: $text; }
|
||||||
|
.modal-close {
|
||||||
|
width: 56rpx; height: 56rpx; @include flex-center; border-radius: 50%; background: $border;
|
||||||
|
&:active { background: darken($border, 5%); }
|
||||||
|
}
|
||||||
|
.modal-close-text { font-size: $font-2xl; color: $text-sec; line-height: 1; }
|
||||||
|
.modal-body { flex: 1; padding: $space-md $space-xl; max-height: 45vh; }
|
||||||
|
.modal-footer {
|
||||||
|
padding: $space-md $space-xl; border-top: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-text {
|
||||||
|
display: block; text-align: center; font-size: 30rpx; font-weight: 600; color: $surface;
|
||||||
|
padding: $space-lg; background: linear-gradient(135deg, $primary, #E67355);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 类型切换 */
|
||||||
|
.type-toggle-wrap { display: flex; justify-content: center; margin-bottom: $space-md; }
|
||||||
|
.type-toggle {
|
||||||
|
display: inline-flex; background: $surface-warm; border-radius: $radius-lg;
|
||||||
|
padding: $space-xs; position: relative;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
width: 140rpx; height: 64rpx; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: $font-md; color: $text-sec; border-radius: $radius-md; position: relative; z-index: 1;
|
||||||
|
&.active { color: $primary; font-weight: 600; }
|
||||||
|
}
|
||||||
|
.slider {
|
||||||
|
position: absolute; top: $space-xs; left: $space-xs; width: 140rpx; height: 64rpx;
|
||||||
|
background: $surface; border-radius: $radius-md; box-shadow: $shadow-sm;
|
||||||
|
transition: transform $transition-normal;
|
||||||
|
}
|
||||||
|
.income .slider { transform: translateX(140rpx); }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* 分类 */
|
||||||
|
.category-scroll { margin-bottom: $space-md; }
|
||||||
|
.category-row { display: flex; gap: $space-sm; padding: $space-xs 0; }
|
||||||
|
.cat-item {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6rpx;
|
||||||
|
padding: 8rpx 12rpx; min-width: 100rpx;
|
||||||
|
&:active { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
.cat-name { font-size: $font-sm; color: $text-sec; &.active { color: $primary; font-weight: 600; } }
|
||||||
|
|
||||||
|
/* 表单项 */
|
||||||
|
.form-item {
|
||||||
|
display: flex; align-items: center; gap: $space-sm;
|
||||||
|
height: 80rpx; margin-bottom: $space-sm;
|
||||||
|
padding: 0 $space-md; background: $bg; border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
.form-label { font-size: $font-md; color: $text-sec; flex-shrink: 0; margin-right: $space-sm; }
|
||||||
|
.form-input { flex: 1; font-size: $font-md; color: $text; }
|
||||||
|
.form-picker { flex: 1; }
|
||||||
|
.form-picker-text { font-size: $font-md; color: $text; &.placeholder { color: $text-muted; } }
|
||||||
|
.freq-options { display: flex; gap: $space-xs; }
|
||||||
|
.freq-tab {
|
||||||
|
padding: $space-xs $space-lg; border-radius: $radius-md;
|
||||||
|
font-size: $font-md; color: $text-sec; background: $surface;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
&.active { background: $primary; color: $surface; border-color: $primary; }
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -120,9 +120,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
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 { useStatsStore } from '@/stores/stats'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { getTransactions } from '@/api/transaction'
|
import { getTransactions } from '@/api/transaction'
|
||||||
import { getOverview } from '@/api/stats'
|
import { getOverview } from '@/api/stats'
|
||||||
@@ -134,6 +135,7 @@ import ChartWrapper from '@/components/ChartWrapper/ChartWrapper.vue'
|
|||||||
|
|
||||||
const statsStore = useStatsStore()
|
const statsStore = useStatsStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
const currentMonth = ref(getCurrentMonth())
|
const currentMonth = ref(getCurrentMonth())
|
||||||
const tabType = ref<'expense' | 'income'>('expense')
|
const tabType = ref<'expense' | 'income'>('expense')
|
||||||
@@ -235,9 +237,10 @@ onShow(() => {
|
|||||||
if (initialLoaded.value) loadData(true)
|
if (initialLoaded.value) loadData(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Only re-fetch category/trend on tab switch; re-fetch all on month change
|
// 月份变化时重新请求所有数据
|
||||||
watch(currentMonth, () => loadData())
|
watch(currentMonth, () => loadData())
|
||||||
watch(tabType, () => loadData(true))
|
// tab 切换时只请求分类和趋势数据
|
||||||
|
watch(tabType, () => loadTabData())
|
||||||
|
|
||||||
async function loadData(silent = false) {
|
async function loadData(silent = false) {
|
||||||
const seq = ++loadSeq
|
const seq = ++loadSeq
|
||||||
@@ -261,6 +264,22 @@ async function loadData(silent = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tab 切换时只加载分类和趋势数据(不重复请求上月对比和最大单笔)
|
||||||
|
async function loadTabData() {
|
||||||
|
const seq = ++loadSeq
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
|
||||||
|
statsStore.fetchTrend(currentMonth.value, tabType.value),
|
||||||
|
fetchMaxSingle()
|
||||||
|
])
|
||||||
|
if (seq !== loadSeq) return
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== loadSeq) return
|
||||||
|
console.error('Stats tab load error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchMaxSingle() {
|
async function fetchMaxSingle() {
|
||||||
try {
|
try {
|
||||||
const [y, m] = currentMonth.value.split('-').map(Number)
|
const [y, m] = currentMonth.value.split('-').map(Number)
|
||||||
@@ -295,6 +314,20 @@ onPullDownRefresh(() => {
|
|||||||
loadData().finally(() => uni.stopPullDownRefresh())
|
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() {
|
function prevMonth() {
|
||||||
const [y, m] = currentMonth.value.split('-').map(Number)
|
const [y, m] = currentMonth.value.split('-').map(Number)
|
||||||
currentMonth.value = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}`
|
currentMonth.value = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}`
|
||||||
@@ -313,46 +346,36 @@ function getBarWidth(amount: number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
@include page-base;
|
||||||
background: #FFF8F0;
|
|
||||||
padding: 0 0 180rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
position: sticky;
|
@include sticky-header;
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
background: #FFF8F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
background: #FFF8F0;
|
@include status-bar;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
display: block;
|
@include page-title;
|
||||||
text-align: center;
|
|
||||||
font-size: 36rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #2D1B1B;
|
|
||||||
padding: 16rpx 0 24rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.month-picker {
|
.month-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 48rpx;
|
gap: $space-2xl;
|
||||||
padding: 16rpx 0;
|
padding: $space-sm 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow {
|
.arrow {
|
||||||
width: 88rpx;
|
width: 88rpx;
|
||||||
height: 88rpx;
|
height: 88rpx;
|
||||||
display: flex;
|
@include flex-center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&.disabled { opacity: 0.5; }
|
&.disabled { opacity: 0.5; }
|
||||||
|
|
||||||
@@ -361,42 +384,35 @@ function getBarWidth(amount: number) {
|
|||||||
|
|
||||||
.month-text {
|
.month-text {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 500;
|
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 {
|
.tabs {
|
||||||
display: inline-flex;
|
@include tabs;
|
||||||
background: #FFF0E6;
|
border-radius: $radius-xl;
|
||||||
border-radius: 24rpx;
|
|
||||||
padding: 8rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
padding: 16rpx 40rpx;
|
@include tab;
|
||||||
border-radius: 20rpx;
|
border-radius: $radius-xl;
|
||||||
font-size: 28rpx;
|
|
||||||
color: #8B7E7E;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: #FFFFFF;
|
border-radius: $radius-lg;
|
||||||
color: #FF8C69;
|
box-shadow: $shadow-md;
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-card, .chart-card {
|
.overview-card, .chart-card {
|
||||||
margin: 0 40rpx 32rpx;
|
@include card($radius-2xl);
|
||||||
padding: 40rpx;
|
margin: 0 $space-xl $space-lg;
|
||||||
background: #FFFFFF;
|
padding: $space-xl;
|
||||||
border-radius: 40rpx;
|
|
||||||
border: 2rpx solid #F0E0D6;
|
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-card {
|
.overview-card {
|
||||||
@@ -407,64 +423,61 @@ function getBarWidth(amount: number) {
|
|||||||
|
|
||||||
.extra-stats {
|
.extra-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24rpx;
|
gap: $space-md;
|
||||||
margin: 0 40rpx 32rpx;
|
margin: 0 $space-xl $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.es-item {
|
.es-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 24rpx;
|
@include card($radius-xl);
|
||||||
background: #FFFFFF;
|
padding: $space-md;
|
||||||
border-radius: 28rpx;
|
|
||||||
border: 2rpx solid #F0E0D6;
|
|
||||||
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.es-label {
|
.es-label {
|
||||||
font-size: 22rpx;
|
font-size: $font-sm;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: $space-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.es-value {
|
.es-value {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 28rpx;
|
font-size: $font-lg;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
|
|
||||||
&.up { color: #FF6B6B; }
|
&.up { color: $danger; }
|
||||||
&.down { color: #7BC67E; }
|
&.down { color: $success; }
|
||||||
&.same { color: #8B7E7E; }
|
&.same { color: $text-sec; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.ov-label {
|
.ov-label {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 12rpx;
|
margin-bottom: 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ov-value {
|
.ov-value {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 40rpx;
|
font-size: $font-3xl;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-title {
|
.chart-title {
|
||||||
font-size: 32rpx;
|
font-size: $font-xl;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 24rpx;
|
margin-bottom: $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-legend {
|
.chart-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16rpx 32rpx;
|
gap: $space-sm $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-item {
|
.legend-item {
|
||||||
@@ -479,74 +492,73 @@ function getBarWidth(amount: number) {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-text { font-size: 24rpx; color: #8B7E7E; }
|
.legend-text { font-size: $font-md; color: $text-sec; }
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 80rpx 0;
|
padding: 80rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text { font-size: 28rpx; color: #BFB3B3; }
|
.empty-text { font-size: $font-lg; color: $text-muted; }
|
||||||
|
|
||||||
.state-box {
|
.state-box {
|
||||||
display: flex;
|
@include state-box;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 16rpx;
|
|
||||||
padding: 80rpx 0;
|
padding: 80rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-text {
|
.state-text {
|
||||||
font-size: 28rpx;
|
@include state-text;
|
||||||
color: #BFB3B3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-list { display: flex; flex-direction: column; gap: 16rpx; }
|
.trend-list { display: flex; flex-direction: column; gap: $space-sm; }
|
||||||
|
|
||||||
.skeleton-legend {
|
.skeleton-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16rpx 32rpx;
|
gap: $space-sm $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-trend {
|
.skeleton-trend {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-item {
|
.trend-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16rpx;
|
gap: $space-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-date {
|
.trend-date {
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-bar-bg {
|
.trend-bar-bg {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 12rpx;
|
height: 12rpx;
|
||||||
border-radius: 6rpx;
|
border-radius: 6rpx;
|
||||||
background: #F0E0D6;
|
background: $border;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-bar {
|
.trend-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 6rpx;
|
border-radius: 6rpx;
|
||||||
background: linear-gradient(90deg, #FF8C69, #FFB89A);
|
background: linear-gradient(90deg, $primary, #FFB89A);
|
||||||
transition: width 0.6s ease-out;
|
transition: width 0.6s ease-out;
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-amount {
|
.trend-amount {
|
||||||
width: 120rpx;
|
width: 120rpx;
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 24rpx;
|
font-size: $font-md;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const useCategoryStore = defineStore('category', () => {
|
|||||||
|
|
||||||
async function migrateCategory(fromId: number, toId: number) {
|
async function migrateCategory(fromId: number, toId: number) {
|
||||||
await api.migrateCategory(fromId, toId)
|
await api.migrateCategory(fromId, toId)
|
||||||
|
await fetchCategories()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sortCategories(ids: number[]) {
|
async function sortCategories(ids: number[]) {
|
||||||
|
|||||||
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,27 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
const isGroupMode = computed(() => currentGroupId.value !== null)
|
const isGroupMode = computed(() => currentGroupId.value !== null)
|
||||||
|
|
||||||
/** 切换到个人模式 */
|
/** 切换到个人模式 */
|
||||||
function switchToPersonal() {
|
async function switchToPersonal() {
|
||||||
currentGroupId.value = null
|
currentGroupId.value = null
|
||||||
uni.removeStorageSync('xc:currentGroupId')
|
uni.removeStorageSync('xc:currentGroupId')
|
||||||
refreshAll()
|
await refreshAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 切换到群组模式 */
|
/** 切换到群组模式 */
|
||||||
function switchToGroup(groupId: number) {
|
async function switchToGroup(groupId: number) {
|
||||||
currentGroupId.value = groupId
|
currentGroupId.value = groupId
|
||||||
uni.setStorageSync('xc:currentGroupId', groupId)
|
uni.setStorageSync('xc:currentGroupId', groupId)
|
||||||
refreshAll()
|
await refreshAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 切换身份后全局刷新所有数据 */
|
/** 切换身份后全局刷新所有数据(串行执行避免数据错乱) */
|
||||||
function refreshAll() {
|
async function refreshAll() {
|
||||||
const statsStore = useStatsStore()
|
const statsStore = useStatsStore()
|
||||||
const budgetStore = useBudgetStore()
|
const budgetStore = useBudgetStore()
|
||||||
const txStore = useTransactionStore()
|
const txStore = useTransactionStore()
|
||||||
statsStore.fetchOverview()
|
await statsStore.fetchOverview()
|
||||||
budgetStore.fetchBudget()
|
await budgetStore.fetchBudget()
|
||||||
txStore.fetchTransactions({ page: 1 })
|
await txStore.fetchTransactions({ page: 1 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取群组列表 */
|
/** 获取群组列表 */
|
||||||
@@ -55,7 +55,7 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
if (currentGroupId.value !== null) {
|
if (currentGroupId.value !== null) {
|
||||||
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
|
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
|
||||||
if (!stillValid) {
|
if (!stillValid) {
|
||||||
switchToPersonal()
|
await switchToPersonal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -83,7 +83,7 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
async function leaveGroup(groupId: number) {
|
async function leaveGroup(groupId: number) {
|
||||||
await api.leaveGroup(groupId)
|
await api.leaveGroup(groupId)
|
||||||
if (currentGroupId.value === groupId) {
|
if (currentGroupId.value === groupId) {
|
||||||
switchToPersonal()
|
await switchToPersonal()
|
||||||
}
|
}
|
||||||
await fetchGroups()
|
await fetchGroups()
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
async function deleteGroup(groupId: number) {
|
async function deleteGroup(groupId: number) {
|
||||||
await api.deleteGroup(groupId)
|
await api.deleteGroup(groupId)
|
||||||
if (currentGroupId.value === groupId) {
|
if (currentGroupId.value === groupId) {
|
||||||
switchToPersonal()
|
await switchToPersonal()
|
||||||
}
|
}
|
||||||
await fetchGroups()
|
await fetchGroups()
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,8 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
function init() {
|
function init() {
|
||||||
const saved = uni.getStorageSync('xc:currentGroupId')
|
const saved = uni.getStorageSync('xc:currentGroupId')
|
||||||
if (saved) {
|
if (saved) {
|
||||||
currentGroupId.value = saved
|
// 确保类型为数字(某些平台可能返回字符串)
|
||||||
|
currentGroupId.value = typeof saved === 'string' ? Number(saved) : saved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,26 +8,42 @@ export const useNotificationStore = defineStore('notification', () => {
|
|||||||
const urgentNotifications = ref<Notification[]>([])
|
const urgentNotifications = ref<Notification[]>([])
|
||||||
|
|
||||||
async function fetchUnreadCount() {
|
async function fetchUnreadCount() {
|
||||||
|
try {
|
||||||
const data = await api.getUnreadCount()
|
const data = await api.getUnreadCount()
|
||||||
unreadCount.value = data.count
|
unreadCount.value = data.count
|
||||||
|
} catch {
|
||||||
|
// 静默失败,不影响页面
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取未读的强提醒公告(首页弹窗用) */
|
/** 获取未读的强提醒公告(首页弹窗用) */
|
||||||
async function fetchUrgentNotifications() {
|
async function fetchUrgentNotifications() {
|
||||||
|
try {
|
||||||
urgentNotifications.value = await api.getPinnedNotifications()
|
urgentNotifications.value = await api.getPinnedNotifications()
|
||||||
|
} catch {
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markRead(id: number) {
|
async function markRead(id: number) {
|
||||||
|
try {
|
||||||
await api.markRead(id)
|
await api.markRead(id)
|
||||||
if (unreadCount.value > 0) unreadCount.value--
|
if (unreadCount.value > 0) unreadCount.value--
|
||||||
// 从强提醒列表中移除
|
// 从强提醒列表中移除
|
||||||
urgentNotifications.value = urgentNotifications.value.filter(n => n.id !== id)
|
urgentNotifications.value = urgentNotifications.value.filter(n => n.id !== id)
|
||||||
|
} catch {
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAllRead() {
|
async function markAllRead() {
|
||||||
|
try {
|
||||||
await api.markAllRead()
|
await api.markAllRead()
|
||||||
unreadCount.value = 0
|
unreadCount.value = 0
|
||||||
urgentNotifications.value = []
|
urgentNotifications.value = []
|
||||||
|
} catch {
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { unreadCount, urgentNotifications, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead }
|
return { unreadCount, urgentNotifications, fetchUnreadCount, fetchUrgentNotifications, markRead, markAllRead }
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
return userInfo.value?.nickname || uni.getStorageSync('xc:nickname') || '小菜'
|
return userInfo.value?.nickname || uni.getStorageSync('xc:nickname') || '小菜'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const slogan = computed(() => {
|
||||||
|
return userInfo.value?.slogan
|
||||||
|
})
|
||||||
|
|
||||||
const avatarUrl = computed(() => {
|
const avatarUrl = computed(() => {
|
||||||
const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || ''
|
const filename = userInfo.value?.avatar_url || uni.getStorageSync('xc:avatar_url') || ''
|
||||||
if (!filename) return ''
|
if (!filename) return ''
|
||||||
@@ -28,10 +32,17 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateProfile(nickname: string) {
|
async function updateProfile(data: { nickname?: string; slogan?: string }) {
|
||||||
await api.updateNickname(nickname)
|
const nickname = data.nickname ?? userInfo.value?.nickname ?? ''
|
||||||
if (userInfo.value) userInfo.value.nickname = nickname
|
const slogan = data.slogan ?? userInfo.value?.slogan ?? ''
|
||||||
uni.setStorageSync('xc:nickname', nickname)
|
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) {
|
async function uploadAvatar(filePath: string) {
|
||||||
@@ -41,5 +52,5 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
return avatarUrl.value
|
return avatarUrl.value
|
||||||
}
|
}
|
||||||
|
|
||||||
return { userInfo, nickname, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar }
|
return { userInfo, nickname, slogan, avatarUrl, fetchUserInfo, updateProfile, uploadAvatar }
|
||||||
})
|
})
|
||||||
|
|||||||
249
client/src/styles/mixins.scss
Normal file
249
client/src/styles/mixins.scss
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/* ===== 公共 Mixins ===== */
|
||||||
|
|
||||||
|
// 页面基础样式
|
||||||
|
@mixin page-base {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg;
|
||||||
|
padding-bottom: 180rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 粘性头部
|
||||||
|
@mixin sticky-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态栏
|
||||||
|
@mixin status-bar {
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卡片
|
||||||
|
@mixin card($radius: $radius-2xl) {
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导航栏
|
||||||
|
@mixin nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm $space-xl $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回按钮
|
||||||
|
@mixin nav-back {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面标题
|
||||||
|
@mixin page-title {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
padding: $space-sm 0 $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态空/错误/加载
|
||||||
|
@mixin state-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin state-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
@mixin tabs-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin tabs {
|
||||||
|
display: inline-flex;
|
||||||
|
background: $surface-warm;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin tab {
|
||||||
|
padding: $space-sm $space-xl;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text-sec;
|
||||||
|
transition: all $transition-normal;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $surface;
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹窗
|
||||||
|
@mixin modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高层级弹窗(紧急公告、身份选择等需要覆盖其他弹窗的场景)
|
||||||
|
@mixin modal-mask-high {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 300;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin bottom-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-2xl $radius-2xl 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: $space-lg $space-xl $space-md;
|
||||||
|
border-bottom: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin modal-title {
|
||||||
|
font-size: $font-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin modal-close {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $border;
|
||||||
|
&:active { background: darken($border, 5%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表项
|
||||||
|
@mixin list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-md;
|
||||||
|
padding: $space-lg $space-lg;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框
|
||||||
|
@mixin input {
|
||||||
|
width: 100%;
|
||||||
|
height: 80rpx;
|
||||||
|
padding: 0 $space-md;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按钮
|
||||||
|
@mixin btn-primary {
|
||||||
|
height: 80rpx;
|
||||||
|
padding: 0 $space-lg;
|
||||||
|
background: linear-gradient(135deg, $primary, $primary-dark);
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin btn-primary-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $surface;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin btn-ghost {
|
||||||
|
padding: $space-sm $space-md;
|
||||||
|
background: $surface-warm;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
&:active { background: $border; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin btn-ghost-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shimmer 动画
|
||||||
|
@mixin shimmer {
|
||||||
|
background: linear-gradient(90deg, $border 25%, $surface-warm 50%, $border 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flex 工具
|
||||||
|
@mixin flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin flex-between {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本溢出
|
||||||
|
@mixin text-ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全区域
|
||||||
|
@mixin safe-bottom {
|
||||||
|
padding-bottom: constant(safe-area-inset-bottom);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
@@ -1,31 +1,69 @@
|
|||||||
/* Design Tokens */
|
/* ===== Design Tokens ===== */
|
||||||
|
|
||||||
|
// 颜色系统
|
||||||
$primary: #FF8C69;
|
$primary: #FF8C69;
|
||||||
$primary-light: #FFB89A;
|
$primary-light: #FFE8E0;
|
||||||
$primary-dark: #E67355;
|
$primary-dark: #E67355;
|
||||||
$secondary: #6C9BCF;
|
$secondary: #6C9BCF;
|
||||||
$accent: #FFD166;
|
$accent: #FFD166;
|
||||||
|
|
||||||
$bg: #FFF8F0;
|
$bg: #FFF8F0;
|
||||||
$surface: #FFFFFF;
|
$surface: #FFFFFF;
|
||||||
$surface-warm: #FFF0E6;
|
$surface-warm: #FFF0E6;
|
||||||
|
|
||||||
$text: #2D1B1B;
|
$text: #2D1B1B;
|
||||||
$text-sec: #8B7E7E;
|
$text-sec: #8B7E7E;
|
||||||
$text-muted: #BFB3B3;
|
$text-muted: #BFB3B3;
|
||||||
|
|
||||||
$success: #7BC67E;
|
$success: #7BC67E;
|
||||||
$warning: #FFB347;
|
$warning: #FFB347;
|
||||||
$danger: #FF6B6B;
|
$danger: #FF6B6B;
|
||||||
|
$danger-light: #FFE8E8;
|
||||||
|
|
||||||
$border: #F0E0D6;
|
$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);
|
$space-xs: 8rpx;
|
||||||
$shadow-clay-pressed: inset 2px 2px 6px rgba(45, 27, 27, 0.1);
|
$space-sm: 16rpx;
|
||||||
|
$space-md: 24rpx;
|
||||||
|
$space-lg: 32rpx;
|
||||||
|
$space-xl: 40rpx;
|
||||||
|
$space-2xl: 48rpx;
|
||||||
|
|
||||||
$radius-sm: 8rpx;
|
// 圆角系统
|
||||||
$radius-md: 12rpx;
|
$radius-xs: 8rpx;
|
||||||
$radius-lg: 16rpx;
|
$radius-sm: 12rpx;
|
||||||
$radius-xl: 20rpx;
|
$radius-md: 16rpx;
|
||||||
|
$radius-lg: 20rpx;
|
||||||
|
$radius-xl: 28rpx;
|
||||||
|
$radius-2xl: 40rpx;
|
||||||
$radius-full: 9999rpx;
|
$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 {
|
page {
|
||||||
--u-charts-color-0: #FF8C69;
|
--u-charts-color-0: #FF8C69;
|
||||||
--u-charts-color-1: #FFD166;
|
--u-charts-color-1: #FFD166;
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ const _ready = new Promise<void>(resolve => { _resolve = resolve })
|
|||||||
/** 标记 App 启动完成(登录成功) */
|
/** 标记 App 启动完成(登录成功) */
|
||||||
export function markReady() { _resolve() }
|
export function markReady() { _resolve() }
|
||||||
|
|
||||||
/** 等待 App 就绪,最多等 3 秒超时 */
|
/** 等待 App 就绪,最多等 8 秒超时(小程序登录可能较慢) */
|
||||||
export function waitForReady(): Promise<void> {
|
export function waitForReady(): Promise<void> {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
_ready,
|
_ready,
|
||||||
new Promise<void>(resolve => setTimeout(resolve, 3000))
|
new Promise<void>(resolve => setTimeout(resolve, 8000))
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ export function formatDate(date: string | Date): string {
|
|||||||
if (diff === 1) return '昨天'
|
if (diff === 1) return '昨天'
|
||||||
if (diff === 2) return '前天'
|
if (diff === 2) return '前天'
|
||||||
|
|
||||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
// 跨年显示年份
|
||||||
|
const isCurrentYear = d.getFullYear() === now.getFullYear()
|
||||||
|
return isCurrentYear
|
||||||
|
? `${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
|
: `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMonth(date: string): string {
|
export function formatMonth(date: string): string {
|
||||||
@@ -44,9 +48,12 @@ export function formatMonth(date: string): string {
|
|||||||
return `${year}年${parseInt(month)}月`
|
return `${year}年${parseInt(month)}月`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取当前月份(UTC+8 时区,与服务端保持一致) */
|
||||||
export function getCurrentMonth(): string {
|
export function getCurrentMonth(): string {
|
||||||
const d = new Date()
|
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 {
|
export function formatAmountRaw(fen: number): string {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { API_BASE } from '@/config'
|
import { API_BASE } from '@/config'
|
||||||
|
import type { LoginResult } from '@/api/auth'
|
||||||
|
import { trackApiError } from './tracker'
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
url: string
|
url: string
|
||||||
@@ -6,14 +8,6 @@ interface RequestOptions {
|
|||||||
data?: any
|
data?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResult {
|
|
||||||
token: string
|
|
||||||
userId: number
|
|
||||||
nickname: string
|
|
||||||
avatar_url: string
|
|
||||||
role?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
let isRedirectingToLogin = false
|
let isRedirectingToLogin = false
|
||||||
/** 重登录期间挂起的请求,登录成功后统一重试 */
|
/** 重登录期间挂起的请求,登录成功后统一重试 */
|
||||||
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = []
|
let pendingRetries: Array<{ resolve: (v: any) => void; reject: (e: any) => void; options: RequestOptions }> = []
|
||||||
@@ -53,6 +47,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
|||||||
url: API_BASE + options.url,
|
url: API_BASE + options.url,
|
||||||
method: options.method || 'GET',
|
method: options.method || 'GET',
|
||||||
data: options.data,
|
data: options.data,
|
||||||
|
timeout: 15000, // 15秒超时
|
||||||
header: {
|
header: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
@@ -63,6 +58,10 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
|||||||
if (statusCode === 401) {
|
if (statusCode === 401) {
|
||||||
uni.removeStorageSync('xc:token')
|
uni.removeStorageSync('xc:token')
|
||||||
uni.removeStorageSync('xc:userId')
|
uni.removeStorageSync('xc:userId')
|
||||||
|
uni.removeStorageSync('xc:nickname')
|
||||||
|
uni.removeStorageSync('xc:avatar_url')
|
||||||
|
uni.removeStorageSync('xc:role')
|
||||||
|
uni.removeStorageSync('xc:currentGroupId')
|
||||||
|
|
||||||
// 加入重试队列
|
// 加入重试队列
|
||||||
pendingRetries.push({ resolve, reject, options })
|
pendingRetries.push({ resolve, reject, options })
|
||||||
@@ -91,6 +90,8 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
|||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
resolve(data.data)
|
resolve(data.data)
|
||||||
} else {
|
} else {
|
||||||
|
// 埋点:API 业务错误
|
||||||
|
trackApiError(options.url, statusCode, data.message || '请求失败')
|
||||||
// "用户不存在" 不弹 toast(首次登录竞态)
|
// "用户不存在" 不弹 toast(首次登录竞态)
|
||||||
if (data.code !== 40400 || data.message !== '用户不存在') {
|
if (data.code !== 40400 || data.message !== '用户不存在') {
|
||||||
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
|
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
|
||||||
@@ -99,6 +100,8 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
fail: (err: any) => {
|
fail: (err: any) => {
|
||||||
|
// 埋点:网络错误
|
||||||
|
trackApiError(options.url, 0, '网络错误')
|
||||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||||
reject(err)
|
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 = 5000 // 5秒上报一次
|
||||||
|
const MAX_QUEUE_SIZE = 20
|
||||||
|
|
||||||
|
/** 获取当前页面路径 */
|
||||||
|
function getCurrentPage(): string {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length > 0) {
|
||||||
|
return `/${pages[pages.length - 1].route}`
|
||||||
|
}
|
||||||
|
return '/unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量上报事件 */
|
||||||
|
async function flushEvents() {
|
||||||
|
if (eventQueue.length === 0) return
|
||||||
|
|
||||||
|
const events = [...eventQueue]
|
||||||
|
eventQueue = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
url: '/track',
|
||||||
|
method: 'POST',
|
||||||
|
data: { events }
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 上报失败不处理,避免影响用户体验
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加事件到队列 */
|
||||||
|
function addToQueue(event: TrackEvent) {
|
||||||
|
eventQueue.push(event)
|
||||||
|
|
||||||
|
// 队列满时立即上报
|
||||||
|
if (eventQueue.length >= MAX_QUEUE_SIZE) {
|
||||||
|
flushEvents()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置定时上报
|
||||||
|
if (!flushTimer) {
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null
|
||||||
|
flushEvents()
|
||||||
|
}, FLUSH_INTERVAL) as unknown as number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪页面访问
|
||||||
|
*/
|
||||||
|
export function trackPageView(pagePath?: string) {
|
||||||
|
addToQueue({
|
||||||
|
event: 'page_view',
|
||||||
|
page: pagePath || getCurrentPage()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪用户操作
|
||||||
|
* @param action 操作名称,如 'click_save', 'submit_form'
|
||||||
|
* @param data 附加数据
|
||||||
|
*/
|
||||||
|
export function trackAction(action: string, data?: Record<string, any>) {
|
||||||
|
addToQueue({
|
||||||
|
event: 'action',
|
||||||
|
page: getCurrentPage(),
|
||||||
|
data: { action, ...data }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪错误
|
||||||
|
*/
|
||||||
|
export function trackError(error: Error | string, extra?: Record<string, any>) {
|
||||||
|
const errorInfo = typeof error === 'string'
|
||||||
|
? { message: error }
|
||||||
|
: { message: error.message, stack: error.stack?.slice(0, 500) }
|
||||||
|
|
||||||
|
addToQueue({
|
||||||
|
event: 'error',
|
||||||
|
page: getCurrentPage(),
|
||||||
|
data: { ...errorInfo, ...extra }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪 API 请求失败
|
||||||
|
*/
|
||||||
|
export function trackApiError(url: string, status: number, message: string) {
|
||||||
|
addToQueue({
|
||||||
|
event: 'api_error',
|
||||||
|
page: getCurrentPage(),
|
||||||
|
data: { url, status, message }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置全局错误监听
|
||||||
|
*/
|
||||||
|
export function setupErrorTracking() {
|
||||||
|
// Vue 错误处理
|
||||||
|
uni.onError((err: string) => {
|
||||||
|
trackError(err, { type: 'global' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 未处理的 Promise rejection
|
||||||
|
// #ifdef H5
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
trackError(String(event.reason), { type: 'unhandledrejection' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
trackPageView,
|
||||||
|
trackAction,
|
||||||
|
trackError,
|
||||||
|
trackApiError,
|
||||||
|
setupErrorTracking
|
||||||
|
}
|
||||||
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 文件
|
||||||
|
- 不做无关重构
|
||||||
|
- 不做数据库结构大改(如需迁移需单独评审)
|
||||||
@@ -148,6 +148,135 @@ async function runMigrations(conn: mysql.Connection) {
|
|||||||
await conn.query('ALTER TABLE notifications ADD INDEX idx_pinned (is_pinned, created_at)')
|
await conn.query('ALTER TABLE notifications ADD INDEX idx_pinned (is_pinned, created_at)')
|
||||||
await conn.query('ALTER TABLE notifications ADD INDEX idx_expire (expire_at)')
|
await conn.query('ALTER TABLE notifications ADD INDEX idx_expire (expire_at)')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公告已读记录表(解决系统/群组公告已读状态共享问题)
|
||||||
|
const hasNotificationReads = await tableExists(conn, 'notification_reads')
|
||||||
|
if (!hasNotificationReads) {
|
||||||
|
console.log('[DB] Migrating: creating notification_reads table')
|
||||||
|
await conn.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||||
|
notification_id INT NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (notification_id, user_id),
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// users 表添加 slogan(个性签名)
|
||||||
|
const hasSlogan = await columnExists(conn, 'users', 'slogan')
|
||||||
|
if (!hasSlogan) {
|
||||||
|
console.log('[DB] Migrating: adding slogan to users')
|
||||||
|
await conn.query("ALTER TABLE users ADD COLUMN slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名' AFTER avatar_url")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统配置表
|
||||||
|
const hasSysConfig = await tableExists(conn, 'sys_config')
|
||||||
|
if (!hasSysConfig) {
|
||||||
|
console.log('[DB] Migrating: creating sys_config table')
|
||||||
|
await conn.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_config (
|
||||||
|
config_key VARCHAR(50) PRIMARY KEY COMMENT '配置键',
|
||||||
|
config_value TEXT NOT NULL COMMENT '配置值',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`)
|
||||||
|
// 插入默认配置
|
||||||
|
await conn.query(`
|
||||||
|
INSERT IGNORE INTO sys_config (config_key, config_value) VALUES
|
||||||
|
('app_name', '小菜记账'),
|
||||||
|
('app_version', '1.0.0'),
|
||||||
|
('app_slogan', '温暖 x 轻松的个人记账小程序'),
|
||||||
|
('app_description', '让记账从负担变成享受')
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 app_name 配置存在(兼容已有数据库)
|
||||||
|
const [appNameRows] = await conn.query(
|
||||||
|
"SELECT COUNT(*) as cnt FROM sys_config WHERE config_key = 'app_name'"
|
||||||
|
)
|
||||||
|
if ((appNameRows as any[])[0].cnt === 0) {
|
||||||
|
await conn.query("INSERT INTO sys_config (config_key, config_value) VALUES ('app_name', '小菜记账')")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反馈表
|
||||||
|
const hasFeedbacks = await tableExists(conn, 'feedbacks')
|
||||||
|
if (!hasFeedbacks) {
|
||||||
|
console.log('[DB] Migrating: creating feedbacks table')
|
||||||
|
await conn.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS feedbacks (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
type ENUM('bug', 'feature', 'ux', 'other') NOT NULL DEFAULT 'other' COMMENT '反馈类型',
|
||||||
|
content TEXT NOT NULL COMMENT '反馈内容',
|
||||||
|
contact VARCHAR(100) DEFAULT '' COMMENT '联系方式',
|
||||||
|
status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态',
|
||||||
|
admin_reply TEXT COMMENT '管理员回复',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_created (created_at),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 埋点事件表
|
||||||
|
const hasTrackEvents = await tableExists(conn, 'track_events')
|
||||||
|
if (!hasTrackEvents) {
|
||||||
|
console.log('[DB] Migrating: creating track_events table')
|
||||||
|
await conn.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS track_events (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
event VARCHAR(50) NOT NULL COMMENT '事件类型',
|
||||||
|
page VARCHAR(100) DEFAULT '' COMMENT '页面路径',
|
||||||
|
data JSON DEFAULT NULL COMMENT '事件数据',
|
||||||
|
user_id INT DEFAULT NULL COMMENT '用户ID',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_event (event),
|
||||||
|
INDEX idx_created (created_at),
|
||||||
|
INDEX idx_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 周期账单模板表
|
||||||
|
const hasRecurring = await tableExists(conn, 'recurring_templates')
|
||||||
|
if (!hasRecurring) {
|
||||||
|
console.log('[DB] Migrating: creating recurring_templates table')
|
||||||
|
await conn.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS recurring_templates (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
amount INT NOT NULL COMMENT '单位:分',
|
||||||
|
type ENUM('expense', 'income') NOT NULL,
|
||||||
|
category_id INT NOT NULL,
|
||||||
|
note VARCHAR(200) DEFAULT '',
|
||||||
|
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
|
||||||
|
next_date DATE NOT NULL COMMENT '下次生成日期',
|
||||||
|
end_date DATE DEFAULT NULL COMMENT '截止日期,NULL=永久',
|
||||||
|
is_active TINYINT(1) DEFAULT 1,
|
||||||
|
group_id INT DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_next_date (next_date),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// transactions 表添加 recurring_id
|
||||||
|
const hasRecurringId = await columnExists(conn, 'transactions', 'recurring_id')
|
||||||
|
if (!hasRecurringId) {
|
||||||
|
console.log('[DB] Migrating: adding recurring_id to transactions')
|
||||||
|
await conn.query("ALTER TABLE transactions ADD COLUMN recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID' AFTER group_id")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initDatabase() {
|
export async function initDatabase() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
session_key VARCHAR(100),
|
session_key VARCHAR(100),
|
||||||
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
|
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
|
||||||
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
||||||
|
slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名',
|
||||||
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
|
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
@@ -31,7 +32,8 @@ CREATE TABLE IF NOT EXISTS transactions (
|
|||||||
category_id INT,
|
category_id INT,
|
||||||
note VARCHAR(200),
|
note VARCHAR(200),
|
||||||
date DATE NOT NULL,
|
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,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
INDEX idx_user_date (user_id, date),
|
INDEX idx_user_date (user_id, date),
|
||||||
@@ -81,7 +83,7 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||||||
type ENUM('system', 'group', 'personal') NOT NULL,
|
type ENUM('system', 'group', 'personal') NOT NULL,
|
||||||
title VARCHAR(100) NOT NULL,
|
title VARCHAR(100) NOT NULL,
|
||||||
content TEXT,
|
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_pinned TINYINT(1) DEFAULT 0 COMMENT '是否置顶',
|
||||||
is_urgent TINYINT(1) DEFAULT 0 COMMENT '是否强提醒弹窗',
|
is_urgent TINYINT(1) DEFAULT 0 COMMENT '是否强提醒弹窗',
|
||||||
publish_at TIMESTAMP NULL COMMENT '定时发布时间(NULL=立即发布)',
|
publish_at TIMESTAMP NULL COMMENT '定时发布时间(NULL=立即发布)',
|
||||||
@@ -95,6 +97,17 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||||||
INDEX idx_expire (expire_at)
|
INDEX idx_expire (expire_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS saved_filters (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
user_id INT NOT NULL,
|
user_id INT NOT NULL,
|
||||||
@@ -103,3 +116,57 @@ CREATE TABLE IF NOT EXISTS saved_filters (
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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;
|
||||||
|
|||||||
@@ -15,3 +15,10 @@ INSERT IGNORE INTO categories (user_id, name, icon, color, type, sort_order) VAL
|
|||||||
(0, '副业', 'TrendingUp', '#7BC67E', 'income', 2),
|
(0, '副业', 'TrendingUp', '#7BC67E', 'income', 2),
|
||||||
(0, '理财', 'PieChart', '#7BC67E', 'income', 3),
|
(0, '理财', 'PieChart', '#7BC67E', 'income', 3),
|
||||||
(0, '其他收入', 'Plus', '#BFB3B3', 'income', 4);
|
(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', '让记账从负担变成享受');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import rateLimit from 'express-rate-limit'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { authMiddleware } from './middleware/auth'
|
import { authMiddleware } from './middleware/auth'
|
||||||
|
import { requestLogger } from './middleware/logger'
|
||||||
import { initDatabase } from './db/init'
|
import { initDatabase } from './db/init'
|
||||||
import pool from './db/connection'
|
import pool from './db/connection'
|
||||||
import authRoutes from './routes/auth'
|
import authRoutes from './routes/auth'
|
||||||
@@ -18,6 +19,11 @@ import groupRoutes from './routes/group'
|
|||||||
import notificationRoutes from './routes/notification'
|
import notificationRoutes from './routes/notification'
|
||||||
import filterRoutes from './routes/filter'
|
import filterRoutes from './routes/filter'
|
||||||
import adminRoutes from './routes/admin'
|
import adminRoutes from './routes/admin'
|
||||||
|
import feedbackRoutes from './routes/feedback'
|
||||||
|
import configRoutes from './routes/config'
|
||||||
|
import trackRoutes from './routes/track'
|
||||||
|
import logsRoutes from './routes/logs'
|
||||||
|
import recurringRoutes from './routes/recurring'
|
||||||
import { backupDatabase } from './utils/backup'
|
import { backupDatabase } from './utils/backup'
|
||||||
|
|
||||||
// Warn if token secret is using default fallback
|
// Warn if token secret is using default fallback
|
||||||
@@ -46,6 +52,9 @@ app.use(cors({
|
|||||||
}))
|
}))
|
||||||
app.use(express.json({ limit: '10kb' }))
|
app.use(express.json({ limit: '10kb' }))
|
||||||
|
|
||||||
|
// 请求日志
|
||||||
|
app.use(requestLogger)
|
||||||
|
|
||||||
// 限流:登录接口 — 每分钟最多 10 次
|
// 限流:登录接口 — 每分钟最多 10 次
|
||||||
const authLimiter = rateLimit({
|
const authLimiter = rateLimit({
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
@@ -100,6 +109,42 @@ app.get('/api/user/avatar/:filename', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 通知图片 API(公开,不经过 auth 中间件)
|
||||||
|
app.get('/api/notifications/image/:filename', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const uploadDir = path.resolve(process.env.UPLOAD_DIR || './uploads')
|
||||||
|
const notifDir = path.join(uploadDir, 'notifications')
|
||||||
|
const filename = path.basename(req.params.filename)
|
||||||
|
|
||||||
|
// 文件名格式校验:允许 notif_用户ID_数字.ext
|
||||||
|
if (!/^notif_\d+_\d+\.(jpg|jpeg|png|webp)$/i.test(filename)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '无效文件名' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const filepath = path.join(notifDir, filename)
|
||||||
|
|
||||||
|
// 路径穿越检查
|
||||||
|
if (!path.resolve(filepath).startsWith(path.resolve(notifDir))) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '无效路径' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(filepath)) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '图片不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filename).toLowerCase()
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png', '.webp': 'image/webp'
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream')
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||||
|
fs.createReadStream(filepath).pipe(res)
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.use(authMiddleware)
|
app.use(authMiddleware)
|
||||||
|
|
||||||
app.use('/api/auth', authLimiter, authRoutes)
|
app.use('/api/auth', authLimiter, authRoutes)
|
||||||
@@ -113,6 +158,11 @@ app.use('/api/groups', apiLimiter, groupRoutes)
|
|||||||
app.use('/api/notifications', apiLimiter, notificationRoutes)
|
app.use('/api/notifications', apiLimiter, notificationRoutes)
|
||||||
app.use('/api/filters', apiLimiter, filterRoutes)
|
app.use('/api/filters', apiLimiter, filterRoutes)
|
||||||
app.use('/api/admin', apiLimiter, adminRoutes)
|
app.use('/api/admin', apiLimiter, adminRoutes)
|
||||||
|
app.use('/api/feedback', apiLimiter, feedbackRoutes)
|
||||||
|
app.use('/api/config', apiLimiter, configRoutes)
|
||||||
|
app.use('/api/track', apiLimiter, trackRoutes)
|
||||||
|
app.use('/api/logs', apiLimiter, logsRoutes)
|
||||||
|
app.use('/api/recurring', apiLimiter, recurringRoutes)
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +173,12 @@ app.get('/api/health', async (_req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 全局错误处理中间件(兜底未捕获的异常)
|
||||||
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
console.error('[Global] Unhandled error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器内部错误' })
|
||||||
|
})
|
||||||
|
|
||||||
let server: any
|
let server: any
|
||||||
|
|
||||||
// 生产环境:先备份再迁移,迁移出问题可恢复
|
// 生产环境:先备份再迁移,迁移出问题可恢复
|
||||||
|
|||||||
85
server/src/middleware/logger.ts
Normal file
85
server/src/middleware/logger.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// 日志目录
|
||||||
|
const LOG_DIR = path.resolve(process.env.LOG_DIR || './logs')
|
||||||
|
if (!fs.existsSync(LOG_DIR)) {
|
||||||
|
fs.mkdirSync(LOG_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前日期字符串 YYYY-MM-DD(服务器本地时间) */
|
||||||
|
function getDateStr(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化时间 HH:mm:ss.SSS(服务器本地时间) */
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入日志文件 */
|
||||||
|
function writeLog(level: string, message: string, meta?: Record<string, any>) {
|
||||||
|
const dateStr = getDateStr()
|
||||||
|
const logFile = path.join(LOG_DIR, `${dateStr}.log`)
|
||||||
|
const now = new Date()
|
||||||
|
const time = formatTime(now)
|
||||||
|
const timestamp = now.toISOString() // 记录 UTC 时间戳,前端可按用户时区显示
|
||||||
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
|
const line = `[${time}] [${timestamp}] ${level} ${message}${metaStr}\n`
|
||||||
|
|
||||||
|
fs.appendFile(logFile, line, (err) => {
|
||||||
|
if (err) console.error('[Logger] Write error:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求日志中间件 */
|
||||||
|
export function requestLogger(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const { method, url, ip } = req
|
||||||
|
|
||||||
|
// 响应结束时记录日志(此时 authMiddleware 已执行,userId 可用)
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
const { statusCode } = res
|
||||||
|
// 延迟获取 userId,确保 authMiddleware 已执行
|
||||||
|
const userId = (req as any).userId
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
status: statusCode,
|
||||||
|
duration: `${duration}ms`,
|
||||||
|
ip: ip || req.headers['x-forwarded-for'] || 'unknown',
|
||||||
|
userId: userId || null,
|
||||||
|
userAgent: req.headers['user-agent']?.slice(0, 100) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode >= 400) {
|
||||||
|
writeLog('ERROR', `${method} ${url} ${statusCode} ${duration}ms`, meta)
|
||||||
|
} else if (duration > 1000) {
|
||||||
|
writeLog('WARN', `Slow request: ${method} ${url} ${duration}ms`, meta)
|
||||||
|
} else {
|
||||||
|
writeLog('INFO', `${method} ${url} ${statusCode} ${duration}ms`, meta)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 错误日志 */
|
||||||
|
export function logError(message: string, error: Error | any, meta?: Record<string, any>) {
|
||||||
|
writeLog('ERROR', message, {
|
||||||
|
...meta,
|
||||||
|
error: error?.message || String(error),
|
||||||
|
stack: error?.stack?.slice(0, 500) || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务日志 */
|
||||||
|
export function logInfo(message: string, meta?: Record<string, any>) {
|
||||||
|
writeLog('INFO', message, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { requestLogger, logError, logInfo }
|
||||||
18
server/src/middleware/requireAdmin.ts
Normal file
18
server/src/middleware/requireAdmin.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Response, NextFunction } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { AuthRequest } from './auth'
|
||||||
|
|
||||||
|
/** 管理员权限检查中间件 */
|
||||||
|
export async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||||
|
const user = (rows as any[])[0]
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ code: 40300, message: '需要管理员权限' })
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Auth] requireAdmin error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,10 @@
|
|||||||
import { Router, Response, NextFunction } from 'express'
|
import { Router, Response } from 'express'
|
||||||
import pool from '../db/connection'
|
import pool from '../db/connection'
|
||||||
import { AuthRequest } from '../middleware/auth'
|
import { AuthRequest } from '../middleware/auth'
|
||||||
|
import { requireAdmin } from '../middleware/requireAdmin'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
/** 管理员权限检查中间件 */
|
|
||||||
async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
|
||||||
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
|
||||||
const user = (rows as any[])[0]
|
|
||||||
if (!user || user.role !== 'admin') {
|
|
||||||
return res.status(403).json({ code: 40300, message: '需要管理员权限,请先登录获取管理员身份' })
|
|
||||||
}
|
|
||||||
next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 所有路由都需要管理员权限
|
// 所有路由都需要管理员权限
|
||||||
router.use((req, res, next) => requireAdmin(req, res, next))
|
router.use((req, res, next) => requireAdmin(req, res, next))
|
||||||
|
|
||||||
@@ -22,11 +13,15 @@ router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users')
|
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users')
|
||||||
const [txThisMonth] = await pool.query(
|
const [txThisMonth] = await pool.query(
|
||||||
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
|
`SELECT COUNT(*) as count,
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as totalIncome
|
||||||
FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
|
FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
|
||||||
)
|
)
|
||||||
const [txLastMonth] = await pool.query(
|
const [txLastMonth] = await pool.query(
|
||||||
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
|
`SELECT COUNT(*) as count,
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as totalIncome
|
||||||
FROM transactions
|
FROM transactions
|
||||||
WHERE date >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
|
WHERE date >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
|
||||||
AND date < DATE_FORMAT(NOW(), '%Y-%m-01')`
|
AND date < DATE_FORMAT(NOW(), '%Y-%m-01')`
|
||||||
@@ -41,9 +36,9 @@ router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
|
|||||||
const thisMonth = (txThisMonth as any[])[0]
|
const thisMonth = (txThisMonth as any[])[0]
|
||||||
const lastMonth = (txLastMonth as any[])[0]
|
const lastMonth = (txLastMonth as any[])[0]
|
||||||
let growth = 0
|
let growth = 0
|
||||||
if (lastMonth.total > 0) {
|
if (lastMonth.totalExpense > 0) {
|
||||||
growth = Math.round(((thisMonth.total - lastMonth.total) / lastMonth.total) * 100)
|
growth = Math.round(((thisMonth.totalExpense - lastMonth.totalExpense) / lastMonth.totalExpense) * 100)
|
||||||
} else if (thisMonth.total > 0) {
|
} else if (thisMonth.totalExpense > 0) {
|
||||||
growth = 100
|
growth = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,12 +47,14 @@ router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
|
|||||||
data: {
|
data: {
|
||||||
totalUsers: (userCount as any[])[0].count,
|
totalUsers: (userCount as any[])[0].count,
|
||||||
monthlyTxCount: thisMonth.count,
|
monthlyTxCount: thisMonth.count,
|
||||||
monthlyTxAmount: thisMonth.total,
|
monthlyExpense: thisMonth.totalExpense,
|
||||||
|
monthlyIncome: thisMonth.totalIncome,
|
||||||
dailyActive: (activeToday as any[])[0].count,
|
dailyActive: (activeToday as any[])[0].count,
|
||||||
monthlyActive: (activeThisMonth as any[])[0].count,
|
monthlyActive: (activeThisMonth as any[])[0].count,
|
||||||
growthPercent: growth,
|
growthPercent: growth,
|
||||||
lastMonthTxCount: lastMonth.count,
|
lastMonthTxCount: lastMonth.count,
|
||||||
lastMonthTxAmount: lastMonth.total
|
lastMonthExpense: lastMonth.totalExpense,
|
||||||
|
lastMonthIncome: lastMonth.totalIncome
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -78,8 +75,10 @@ router.get('/users', async (req: AuthRequest, res: Response) => {
|
|||||||
const params: any[] = []
|
const params: any[] = []
|
||||||
|
|
||||||
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
||||||
|
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
|
||||||
|
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
|
||||||
where = 'WHERE u.nickname LIKE ?'
|
where = 'WHERE u.nickname LIKE ?'
|
||||||
params.push(`%${keyword.trim()}%`)
|
params.push(`%${escapedKeyword}%`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
@@ -125,6 +124,17 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ code: 40001, message: '不能修改自己的角色' })
|
return res.status(400).json({ code: 40001, message: '不能修改自己的角色' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果要降级管理员,检查是否至少保留一个管理员
|
||||||
|
if (role === 'user') {
|
||||||
|
const [adminCount] = await pool.query(
|
||||||
|
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
|
||||||
|
[req.params.id]
|
||||||
|
)
|
||||||
|
if ((adminCount as any[])[0].count === 0) {
|
||||||
|
return res.status(400).json({ code: 40002, message: '至少需要保留一个管理员' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id])
|
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id])
|
||||||
if ((result as any).affectedRows === 0) {
|
if ((result as any).affectedRows === 0) {
|
||||||
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||||
@@ -144,6 +154,19 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ code: 40001, message: '不能删除自己' })
|
return res.status(400).json({ code: 40001, message: '不能删除自己' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否是管理员,如果是则确保至少保留一个
|
||||||
|
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.params.id])
|
||||||
|
const user = (userRows as any[])[0]
|
||||||
|
if (user && user.role === 'admin') {
|
||||||
|
const [adminCount] = await pool.query(
|
||||||
|
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
|
||||||
|
[req.params.id]
|
||||||
|
)
|
||||||
|
if ((adminCount as any[])[0].count === 0) {
|
||||||
|
return res.status(400).json({ code: 40002, message: '至少需要保留一个管理员' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [req.params.id])
|
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [req.params.id])
|
||||||
if ((result as any).affectedRows === 0) {
|
if ((result as any).affectedRows === 0) {
|
||||||
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
import { Router, Response } from 'express'
|
import { Router, Response } from 'express'
|
||||||
import { AuthRequest } from '../middleware/auth'
|
import { AuthRequest } from '../middleware/auth'
|
||||||
import { backupDatabase, getBackupList } from '../utils/backup'
|
import { backupDatabase, getBackupList } from '../utils/backup'
|
||||||
|
import { requireAdmin } from '../middleware/requireAdmin'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
// 管理员用户 ID 列表(逗号分隔),未配置时默认仅 ID=1
|
// 所有路由都需要管理员权限
|
||||||
const ADMIN_IDS = process.env.ADMIN_USER_IDS
|
router.use((req, res, next) => requireAdmin(req, res, next))
|
||||||
? process.env.ADMIN_USER_IDS.split(',').map(Number)
|
|
||||||
: [1]
|
|
||||||
|
|
||||||
/** 检查是否为管理员,非管理员返回 403 */
|
|
||||||
function requireAdmin(req: AuthRequest, res: Response): boolean {
|
|
||||||
if (!req.userId || !ADMIN_IDS.includes(req.userId)) {
|
|
||||||
res.status(403).json({ code: 40300, message: '无权限' })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 手动触发备份
|
// 手动触发备份
|
||||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
if (!requireAdmin(req, res)) return
|
|
||||||
try {
|
try {
|
||||||
const filepath = await backupDatabase()
|
await backupDatabase()
|
||||||
res.json({ code: 0, data: { filepath } })
|
res.json({ code: 0, message: '备份成功' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Backup] 手动备份失败:', err)
|
console.error('[Backup] 手动备份失败:', err)
|
||||||
res.status(500).json({ code: 50000, message: '备份失败' })
|
res.status(500).json({ code: 50000, message: '备份失败' })
|
||||||
@@ -32,7 +21,6 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
// 获取备份列表
|
// 获取备份列表
|
||||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
if (!requireAdmin(req, res)) return
|
|
||||||
try {
|
try {
|
||||||
const list = getBackupList()
|
const list = getBackupList()
|
||||||
res.json({ code: 0, data: list })
|
res.json({ code: 0, data: list })
|
||||||
|
|||||||
@@ -25,19 +25,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [groupRows] = await pool.query(
|
const [groupRows] = await pool.query(
|
||||||
`SELECT COALESCE(SUM(amount), 0) as amount, month
|
`SELECT SUM(amount) as amount, month
|
||||||
FROM budgets
|
FROM budgets
|
||||||
WHERE user_id IN (SELECT user_id FROM group_members WHERE group_id = ?) AND month = ?
|
WHERE user_id IN (SELECT user_id FROM group_members WHERE group_id = ?) AND month = ?
|
||||||
GROUP BY month`,
|
GROUP BY month`,
|
||||||
[group_id, month]
|
[group_id, month]
|
||||||
)
|
)
|
||||||
const [myRows] = await pool.query(
|
const [myRows] = await pool.query(
|
||||||
'SELECT COALESCE(amount, 0) as amount FROM budgets WHERE user_id = ? AND month = ?',
|
'SELECT amount FROM budgets WHERE user_id = ? AND month = ?',
|
||||||
[req.userId, month]
|
[req.userId, month]
|
||||||
)
|
)
|
||||||
const groupBudget = (groupRows as any[])[0] || { amount: 0, month }
|
const groupBudget = (groupRows as any[])[0]
|
||||||
const myAmount = (myRows as any[])[0]?.amount || 0
|
const myAmount = (myRows as any[])[0]?.amount || 0
|
||||||
|
// 如果没有任何成员设置预算,返回 null
|
||||||
|
if (!groupBudget) {
|
||||||
|
res.json({ code: 0, data: null })
|
||||||
|
} else {
|
||||||
res.json({ code: 0, data: { amount: groupBudget.amount, myAmount, month } })
|
res.json({ code: 0, data: { amount: groupBudget.amount, myAmount, month } })
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 个人视图
|
// 个人视图
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
|
|||||||
@@ -18,6 +18,34 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 更新分类排序(批量更新,使用 CASE WHEN 一次完成)
|
||||||
|
// 注意:此路由必须在 PUT /:id 之前注册,否则 /sort 会被当作 :id 参数
|
||||||
|
router.put('/sort', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { ids } = req.body // [id1, id2, id3, ...]
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '参数无效' })
|
||||||
|
}
|
||||||
|
// 校验每个元素必须是正整数
|
||||||
|
if (!ids.every((id: any) => Number.isInteger(id) && id > 0)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '参数无效' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用参数化查询构建 CASE WHEN 语句
|
||||||
|
const whenClauses = ids.map(() => `WHEN ? THEN ?`).join(' ')
|
||||||
|
const whenParams: number[] = ids.flatMap((id: number, index: number) => [id, index])
|
||||||
|
const placeholders = ids.map(() => '?').join(',')
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE categories SET sort_order = CASE id ${whenClauses} END WHERE id IN (${placeholders}) AND (user_id = 0 OR user_id = ?)`,
|
||||||
|
[...whenParams, ...ids, req.userId]
|
||||||
|
)
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Category] sort error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { name, icon, color, type } = req.body
|
const { name, icon, color, type } = req.body
|
||||||
@@ -96,15 +124,31 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
await conn.beginTransaction()
|
await conn.beginTransaction()
|
||||||
|
|
||||||
// 验证目标分类存在且属于当前用户或是默认分类
|
// 验证源分类存在且属于当前用户(仅允许迁移自定义分类)
|
||||||
|
const [sourceRows] = await conn.query(
|
||||||
|
'SELECT id, type FROM categories WHERE id = ? AND is_custom = 1 AND user_id = ?',
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
)
|
||||||
|
const sourceCategory = (sourceRows as any[])[0]
|
||||||
|
if (!sourceCategory) {
|
||||||
|
await conn.rollback()
|
||||||
|
return res.status(404).json({ code: 40400, message: '源分类不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证目标分类存在且属于当前用户或是默认分类,并且类型一致
|
||||||
const [targetRows] = await conn.query(
|
const [targetRows] = await conn.query(
|
||||||
'SELECT id FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
|
'SELECT id, type FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
|
||||||
[targetId, req.userId]
|
[targetId, req.userId]
|
||||||
)
|
)
|
||||||
if ((targetRows as any[]).length === 0) {
|
const targetCategory = (targetRows as any[])[0]
|
||||||
|
if (!targetCategory) {
|
||||||
await conn.rollback()
|
await conn.rollback()
|
||||||
return res.status(404).json({ code: 40400, message: '目标分类不存在' })
|
return res.status(404).json({ code: 40400, message: '目标分类不存在' })
|
||||||
}
|
}
|
||||||
|
if (sourceCategory.type !== targetCategory.type) {
|
||||||
|
await conn.rollback()
|
||||||
|
return res.status(400).json({ code: 40002, message: '只能迁移到同类型的分类' })
|
||||||
|
}
|
||||||
|
|
||||||
// 迁移记录
|
// 迁移记录
|
||||||
await conn.query(
|
await conn.query(
|
||||||
@@ -129,33 +173,6 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 更新分类排序(批量更新,使用 CASE WHEN 一次完成)
|
|
||||||
router.put('/sort', async (req: AuthRequest, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { ids } = req.body // [id1, id2, id3, ...]
|
|
||||||
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
|
|
||||||
return res.status(400).json({ code: 40001, message: '参数无效' })
|
|
||||||
}
|
|
||||||
// 校验每个元素必须是正整数
|
|
||||||
if (!ids.every((id: any) => Number.isInteger(id) && id > 0)) {
|
|
||||||
return res.status(400).json({ code: 40001, message: '参数无效' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用参数化查询构建 CASE WHEN 语句
|
|
||||||
const whenClauses = ids.map(() => `WHEN ? THEN ?`).join(' ')
|
|
||||||
const whenParams: number[] = ids.flatMap((id: number, index: number) => [id, index])
|
|
||||||
const placeholders = ids.map(() => '?').join(',')
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE categories SET sort_order = CASE id ${whenClauses} END WHERE id IN (${placeholders}) AND (user_id = 0 OR user_id = ?)`,
|
|
||||||
[...whenParams, ...ids, req.userId]
|
|
||||||
)
|
|
||||||
res.json({ code: 0 })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Category] sort error:', err)
|
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
|
|||||||
54
server/src/routes/config.ts
Normal file
54
server/src/routes/config.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { requireAdmin } from '../middleware/requireAdmin'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
/** 获取公开配置(经过全局 authMiddleware,无需管理员权限) */
|
||||||
|
router.get('/', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.execute('SELECT config_key, config_value FROM sys_config')
|
||||||
|
const config: Record<string, string> = {}
|
||||||
|
for (const row of rows as any[]) {
|
||||||
|
config[row.config_key] = row.config_value
|
||||||
|
}
|
||||||
|
res.json({ code: 0, data: config })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get config error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '获取配置失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 允许修改的配置键白名单 */
|
||||||
|
const ALLOWED_KEYS = ['app_name', 'app_version', 'app_slogan', 'app_description']
|
||||||
|
|
||||||
|
/** 更新配置(管理员权限) */
|
||||||
|
router.put('/', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = req.body
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return res.status(400).json({ code: 40001, message: '无效的配置数据' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新配置(只允许白名单内的键)
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (!ALLOWED_KEYS.includes(key)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: `不允许修改配置项: ${key}` })
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || value.length > 500) {
|
||||||
|
return res.status(400).json({ code: 40001, message: `配置值无效或过长: ${key}` })
|
||||||
|
}
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO sys_config (config_key, config_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE config_value = ?',
|
||||||
|
[key, value, value]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update config error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '更新配置失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
129
server/src/routes/feedback.ts
Normal file
129
server/src/routes/feedback.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { authMiddleware } from '../middleware/auth'
|
||||||
|
import { requireAdmin } from '../middleware/requireAdmin'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
/** 用户提交反馈 */
|
||||||
|
router.post('/', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId
|
||||||
|
const { type, content, contact } = req.body
|
||||||
|
|
||||||
|
if (!content || !content.trim()) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请填写反馈内容' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTypes = ['bug', 'feature', 'ux', 'other']
|
||||||
|
const feedbackType = validTypes.includes(type) ? type : 'other'
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
'INSERT INTO feedbacks (user_id, type, content, contact) VALUES (?, ?, ?, ?)',
|
||||||
|
[userId, feedbackType, content.trim(), contact || '']
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Submit feedback error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '提交失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 管理员获取反馈列表 */
|
||||||
|
router.get('/', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = Math.max(1, parseInt(req.query.page as string) || 1)
|
||||||
|
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20))
|
||||||
|
const status = req.query.status as string
|
||||||
|
const offset = (page - 1) * pageSize
|
||||||
|
|
||||||
|
let where = '1=1'
|
||||||
|
const params: any[] = []
|
||||||
|
|
||||||
|
if (status && ['pending', 'processed', 'ignored'].includes(status)) {
|
||||||
|
where += ' AND f.status = ?'
|
||||||
|
params.push(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [countResult] = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as total FROM feedbacks f WHERE ${where}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
const total = (countResult as any[])[0].total
|
||||||
|
|
||||||
|
// LIMIT/OFFSET 直接嵌入 SQL(execute 对参数类型要求严格)
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT f.*, u.nickname, u.avatar_url
|
||||||
|
FROM feedbacks f
|
||||||
|
LEFT JOIN users u ON f.user_id = u.id
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ code: 0, data: { list: rows, total, page, pageSize } })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get feedback list error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '获取失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 管理员更新反馈状态 */
|
||||||
|
router.put('/:id/status', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params
|
||||||
|
const { status, admin_reply } = req.body
|
||||||
|
|
||||||
|
const validStatuses = ['pending', 'processed', 'ignored']
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '无效的状态' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询反馈信息(用于发送通知)
|
||||||
|
const [feedbackRows] = await pool.execute(
|
||||||
|
'SELECT user_id, content FROM feedbacks WHERE id = ?',
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
const feedback = (feedbackRows as any[])[0]
|
||||||
|
|
||||||
|
if (!feedback) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '反馈不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
'UPDATE feedbacks SET status = ?, admin_reply = ? WHERE id = ?',
|
||||||
|
[status, admin_reply || '', id]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 如果有管理员回复,发送通知给反馈提交者
|
||||||
|
if (feedback && admin_reply && admin_reply.trim()) {
|
||||||
|
const statusText: Record<string, string> = {
|
||||||
|
processed: '已处理',
|
||||||
|
ignored: '已忽略',
|
||||||
|
pending: '待处理'
|
||||||
|
}
|
||||||
|
const replyPreview = admin_reply.trim().length > 50
|
||||||
|
? admin_reply.trim().slice(0, 50) + '...'
|
||||||
|
: admin_reply.trim()
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, content)
|
||||||
|
VALUES (?, 'personal', ?, ?)`,
|
||||||
|
[
|
||||||
|
feedback.user_id,
|
||||||
|
`您的反馈已${statusText[status] || '更新'}`,
|
||||||
|
`反馈内容:${feedback.content.slice(0, 30)}...\n\n管理员回复:${replyPreview}`
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update feedback status error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '更新失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -36,6 +36,9 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
|||||||
inviteCode = generateInviteCode()
|
inviteCode = generateInviteCode()
|
||||||
attempts++
|
attempts++
|
||||||
}
|
}
|
||||||
|
if (attempts >= 10) {
|
||||||
|
return res.status(500).json({ code: 50001, message: '生成邀请码失败,请重试' })
|
||||||
|
}
|
||||||
|
|
||||||
const conn = await pool.getConnection()
|
const conn = await pool.getConnection()
|
||||||
try {
|
try {
|
||||||
@@ -103,6 +106,15 @@ router.post('/join', async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(404).json({ code: 40400, message: '邀请码无效' })
|
return res.status(404).json({ code: 40400, message: '邀请码无效' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查群组是否已解散(没有任何成员)
|
||||||
|
const [memberCount] = await pool.query(
|
||||||
|
'SELECT COUNT(*) as count FROM group_members WHERE group_id = ?',
|
||||||
|
[group.id]
|
||||||
|
)
|
||||||
|
if ((memberCount as any[])[0].count === 0) {
|
||||||
|
return res.status(400).json({ code: 40003, message: '该群组已解散' })
|
||||||
|
}
|
||||||
|
|
||||||
// 使用 INSERT IGNORE 避免并发加入时的竞态条件
|
// 使用 INSERT IGNORE 避免并发加入时的竞态条件
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
'INSERT IGNORE INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
|
'INSERT IGNORE INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
|
||||||
@@ -139,37 +151,20 @@ router.post('/:id/leave', async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ code: 40003, message: '群主不能退出,请先转让或解散群组' })
|
return res.status(400).json({ code: 40003, message: '群主不能退出,请先转让或解散群组' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const conn = await pool.getConnection()
|
// 删除成员关系(保留 transactions.group_id,历史记录仍属于该群组)
|
||||||
try {
|
await pool.query(
|
||||||
await conn.beginTransaction()
|
|
||||||
|
|
||||||
// 清除该用户在该群组的记录标签
|
|
||||||
await conn.query(
|
|
||||||
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
|
|
||||||
[req.userId, groupId]
|
|
||||||
)
|
|
||||||
|
|
||||||
// 删除成员关系
|
|
||||||
await conn.query(
|
|
||||||
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
|
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||||
[groupId, req.userId]
|
[groupId, req.userId]
|
||||||
)
|
)
|
||||||
|
|
||||||
await conn.commit()
|
|
||||||
res.json({ code: 0 })
|
res.json({ code: 0 })
|
||||||
} catch (err) {
|
|
||||||
await conn.rollback()
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
conn.release()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Group] leave error:', err)
|
console.error('[Group] leave error:', err)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 解散群组(仅 owner) */
|
/** 解散群组(仅 owner) — 保留群组记录和 transactions.group_id,仅移除所有成员 */
|
||||||
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const [groupRows] = await pool.query(
|
const [groupRows] = await pool.query(
|
||||||
@@ -184,24 +179,10 @@ router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(403).json({ code: 40300, message: '仅群主可解散群组' })
|
return res.status(403).json({ code: 40300, message: '仅群主可解散群组' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const conn = await pool.getConnection()
|
// 移除所有成员(保留群组记录和 transactions.group_id,历史数据不丢失)
|
||||||
try {
|
await pool.query('DELETE FROM group_members WHERE group_id = ?', [req.params.id])
|
||||||
await conn.beginTransaction()
|
|
||||||
|
|
||||||
// 清除该群组所有记录的标签
|
|
||||||
await conn.query('UPDATE transactions SET group_id = NULL WHERE group_id = ?', [req.params.id])
|
|
||||||
|
|
||||||
// 删除群组(CASCADE 自动清理 group_members)
|
|
||||||
await conn.query('DELETE FROM `groups` WHERE id = ?', [req.params.id])
|
|
||||||
|
|
||||||
await conn.commit()
|
|
||||||
res.json({ code: 0 })
|
res.json({ code: 0 })
|
||||||
} catch (err) {
|
|
||||||
await conn.rollback()
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
conn.release()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Group] DELETE error:', err)
|
console.error('[Group] DELETE error:', err)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
@@ -235,6 +216,9 @@ router.post('/:id/refresh-code', async (req: AuthRequest, res: Response) => {
|
|||||||
inviteCode = generateInviteCode()
|
inviteCode = generateInviteCode()
|
||||||
attempts++
|
attempts++
|
||||||
}
|
}
|
||||||
|
if (attempts >= 10) {
|
||||||
|
return res.status(500).json({ code: 50001, message: '生成邀请码失败,请重试' })
|
||||||
|
}
|
||||||
|
|
||||||
await pool.query('UPDATE `groups` SET invite_code = ? WHERE id = ?', [inviteCode, groupId])
|
await pool.query('UPDATE `groups` SET invite_code = ? WHERE id = ?', [inviteCode, groupId])
|
||||||
res.json({ code: 0, data: { invite_code: inviteCode } })
|
res.json({ code: 0, data: { invite_code: inviteCode } })
|
||||||
@@ -299,30 +283,13 @@ router.delete('/:id/members/:userId', async (req: AuthRequest, res: Response) =>
|
|||||||
return res.status(403).json({ code: 40300, message: '仅群主可移除成员' })
|
return res.status(403).json({ code: 40300, message: '仅群主可移除成员' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const conn = await pool.getConnection()
|
// 删除成员关系(保留 transactions.group_id,历史记录仍属于该群组)
|
||||||
try {
|
await pool.query(
|
||||||
await conn.beginTransaction()
|
|
||||||
|
|
||||||
// 清除该成员在群组的记录标签
|
|
||||||
await conn.query(
|
|
||||||
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
|
|
||||||
[targetUserId, groupId]
|
|
||||||
)
|
|
||||||
|
|
||||||
// 删除成员关系
|
|
||||||
await conn.query(
|
|
||||||
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
|
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||||
[groupId, targetUserId]
|
[groupId, targetUserId]
|
||||||
)
|
)
|
||||||
|
|
||||||
await conn.commit()
|
|
||||||
res.json({ code: 0 })
|
res.json({ code: 0 })
|
||||||
} catch (err) {
|
|
||||||
await conn.rollback()
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
conn.release()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Group] remove member error:', err)
|
console.error('[Group] remove member error:', err)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
|||||||
233
server/src/routes/logs.ts
Normal file
233
server/src/routes/logs.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { requireAdmin } from '../middleware/requireAdmin'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
const LOG_DIR = path.resolve(process.env.LOG_DIR || './logs')
|
||||||
|
|
||||||
|
/** 获取今日日期字符串 YYYY-MM-DD(服务器本地时间) */
|
||||||
|
function getTodayStr(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取日志列表(最近7天) */
|
||||||
|
router.get('/files', requireAdmin, (_req, res) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(LOG_DIR)) {
|
||||||
|
return res.json({ code: 0, data: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(LOG_DIR)
|
||||||
|
.filter(f => f.endsWith('.log'))
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
.slice(0, 7) // 最多返回7天
|
||||||
|
|
||||||
|
const fileList = files.map(f => {
|
||||||
|
const filePath = path.join(LOG_DIR, f)
|
||||||
|
const stat = fs.statSync(filePath)
|
||||||
|
return {
|
||||||
|
name: f,
|
||||||
|
date: f.replace('.log', ''),
|
||||||
|
size: stat.size,
|
||||||
|
modified: stat.mtime.toISOString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ code: 0, data: fileList })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Logs] List error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '获取日志列表失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 获取日志统计 */
|
||||||
|
router.get('/stats', requireAdmin, (_req, res) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(LOG_DIR)) {
|
||||||
|
return res.json({ code: 0, data: { today: { total: 0, errors: 0, slow: 0 }, files: [] } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = getTodayStr()
|
||||||
|
const todayFile = path.join(LOG_DIR, `${dateStr}.log`)
|
||||||
|
|
||||||
|
let todayStats = { total: 0, errors: 0, slow: 0, warns: 0 }
|
||||||
|
|
||||||
|
if (fs.existsSync(todayFile)) {
|
||||||
|
const content = fs.readFileSync(todayFile, 'utf-8')
|
||||||
|
const lines = content.split('\n').filter(l => l.trim())
|
||||||
|
todayStats.total = lines.length
|
||||||
|
todayStats.errors = lines.filter(l => l.includes(' ERROR ')).length
|
||||||
|
todayStats.slow = lines.filter(l => l.includes('Slow request')).length
|
||||||
|
todayStats.warns = lines.filter(l => l.includes(' WARN ')).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最近7天统计
|
||||||
|
const files = fs.readdirSync(LOG_DIR)
|
||||||
|
.filter(f => f.endsWith('.log'))
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
.slice(0, 7)
|
||||||
|
|
||||||
|
const dailyStats = files.map(f => {
|
||||||
|
const filePath = path.join(LOG_DIR, f)
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const lines = content.split('\n').filter(l => l.trim())
|
||||||
|
return {
|
||||||
|
date: f.replace('.log', ''),
|
||||||
|
total: lines.length,
|
||||||
|
errors: lines.filter(l => l.includes(' ERROR ')).length,
|
||||||
|
slow: lines.filter(l => l.includes('Slow request')).length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
today: todayStats,
|
||||||
|
daily: dailyStats
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Logs] Stats error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '获取日志统计失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 获取埋点统计(必须在 /:date 之前注册) */
|
||||||
|
router.get('/track/stats', requireAdmin, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
// 今日统计
|
||||||
|
const [todayRows] = await pool.execute(
|
||||||
|
`SELECT event, COUNT(*) as count FROM track_events
|
||||||
|
WHERE DATE(created_at) = CURDATE()
|
||||||
|
GROUP BY event ORDER BY count DESC`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 最近7天每日统计
|
||||||
|
const [dailyRows] = await pool.execute(
|
||||||
|
`SELECT DATE(created_at) as date, COUNT(*) as count FROM track_events
|
||||||
|
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||||
|
GROUP BY DATE(created_at) ORDER BY date DESC`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 最近7天事件分布
|
||||||
|
const [eventRows] = await pool.execute(
|
||||||
|
`SELECT event, COUNT(*) as count FROM track_events
|
||||||
|
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||||
|
GROUP BY event ORDER BY count DESC LIMIT 10`
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
today: todayRows,
|
||||||
|
daily: dailyRows,
|
||||||
|
events: eventRows
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Track] Stats error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '获取埋点统计失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 查询埋点事件(必须在 /:date 之前注册) */
|
||||||
|
router.get('/track/list', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { event, page = '1', pageSize = '50' } = req.query
|
||||||
|
const pNum = Math.max(1, parseInt(page as string) || 1)
|
||||||
|
const pSize = Math.min(200, Math.max(1, parseInt(pageSize as string) || 50))
|
||||||
|
const offset = (pNum - 1) * pSize
|
||||||
|
|
||||||
|
let where = '1=1'
|
||||||
|
const params: any[] = []
|
||||||
|
|
||||||
|
if (event && typeof event === 'string') {
|
||||||
|
where += ' AND t.event = ?'
|
||||||
|
params.push(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [countResult] = await pool.execute(
|
||||||
|
`SELECT COUNT(*) as total FROM track_events t WHERE ${where}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
const total = (countResult as any[])[0].total
|
||||||
|
|
||||||
|
// LIMIT/OFFSET 直接嵌入 SQL(mysql2 execute 对这些参数类型要求严格)
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT t.*, u.nickname
|
||||||
|
FROM track_events t
|
||||||
|
LEFT JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
LIMIT ${pSize} OFFSET ${offset}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: { list: rows, total, page: pNum, pageSize: pSize }
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Track] List error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '查询埋点失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 查询指定日期的日志(必须在 /track/* 之后注册) */
|
||||||
|
router.get('/:date', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date } = req.params
|
||||||
|
const { level, keyword, page = '1', pageSize = '100' } = req.query
|
||||||
|
|
||||||
|
// 日期格式校验
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '日期格式无效' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logFile = path.join(LOG_DIR, `${date}.log`)
|
||||||
|
if (!fs.existsSync(logFile)) {
|
||||||
|
return res.json({ code: 0, data: { lines: [], total: 0 } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(logFile, 'utf-8')
|
||||||
|
let lines = content.split('\n').filter(l => l.trim())
|
||||||
|
|
||||||
|
// 按级别过滤
|
||||||
|
const levelStr = typeof level === 'string' ? level : ''
|
||||||
|
if (levelStr && levelStr !== 'all') {
|
||||||
|
lines = lines.filter(l => l.includes(` ${levelStr.toUpperCase()} `))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按关键词过滤
|
||||||
|
if (keyword && typeof keyword === 'string') {
|
||||||
|
const kw = keyword.toLowerCase()
|
||||||
|
lines = lines.filter(l => l.toLowerCase().includes(kw))
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = lines.length
|
||||||
|
const pNum = Math.max(1, parseInt(page as string) || 1)
|
||||||
|
const pSize = Math.min(500, Math.max(1, parseInt(pageSize as string) || 100))
|
||||||
|
const offset = (pNum - 1) * pSize
|
||||||
|
const paged = lines.slice(offset, offset + pSize)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
lines: paged,
|
||||||
|
total,
|
||||||
|
page: pNum,
|
||||||
|
pageSize: pSize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Logs] Query error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '查询日志失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -1,17 +1,85 @@
|
|||||||
import { Router, Response, NextFunction } from 'express'
|
import { Router, Response, NextFunction } from 'express'
|
||||||
|
import multer from 'multer'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
import pool from '../db/connection'
|
import pool from '../db/connection'
|
||||||
import { AuthRequest } from '../middleware/auth'
|
import { AuthRequest } from '../middleware/auth'
|
||||||
|
import { requireAdmin } from '../middleware/requireAdmin'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
/** 管理员权限检查 */
|
// 通知图片上传配置
|
||||||
async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
|
||||||
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
const NOTIFICATION_DIR = path.resolve(UPLOAD_DIR, 'notifications')
|
||||||
const user = (rows as any[])[0]
|
if (!fs.existsSync(NOTIFICATION_DIR)) {
|
||||||
if (!user || user.role !== 'admin') {
|
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
|
||||||
return res.status(403).json({ code: 40300, message: '需要管理员权限' })
|
}
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (_req, _file, cb) => {
|
||||||
|
if (!fs.existsSync(NOTIFICATION_DIR)) {
|
||||||
|
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
cb(null, NOTIFICATION_DIR)
|
||||||
|
},
|
||||||
|
filename: (req: AuthRequest, file, cb) => {
|
||||||
|
const extMap: Record<string, string> = {
|
||||||
|
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'
|
||||||
|
}
|
||||||
|
const ext = extMap[file.mimetype] || '.jpg'
|
||||||
|
cb(null, `notif_${req.userId}_${Date.now()}${ext}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 }, // 最大 5MB
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const allowed = ['image/jpeg', 'image/png', 'image/webp']
|
||||||
|
if (allowed.includes(file.mimetype)) {
|
||||||
|
cb(null, true)
|
||||||
|
} else {
|
||||||
|
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
|
||||||
|
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT type, group_id FROM notifications WHERE id = ?',
|
||||||
|
[req.params.id]
|
||||||
|
)
|
||||||
|
const notification = (rows as any[])[0]
|
||||||
|
if (!notification) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '公告不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是管理员
|
||||||
|
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||||
|
const user = (userRows as any[])[0]
|
||||||
|
if (user && user.role === 'admin') {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 群组公告:检查是否是群主
|
||||||
|
if (notification.type === 'group' && notification.group_id) {
|
||||||
|
const [groupRows] = await pool.query(
|
||||||
|
'SELECT created_by FROM `groups` WHERE id = ?',
|
||||||
|
[notification.group_id]
|
||||||
|
)
|
||||||
|
const group = (groupRows as any[])[0]
|
||||||
|
if (group && group.created_by === req.userId) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(403).json({ code: 40300, message: '需要管理员或群主权限' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] editAuth error:', err)
|
||||||
|
return res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
}
|
}
|
||||||
next()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取通知列表 */
|
/** 获取通知列表 */
|
||||||
@@ -34,13 +102,18 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at, n.group_id,
|
`SELECT n.id, n.type, n.title, n.content, n.created_at, n.group_id,
|
||||||
n.is_pinned, n.is_urgent, n.publish_at, n.expire_at, n.image_url, n.link_url
|
n.is_pinned, n.is_urgent, n.publish_at, n.expire_at, n.image_url, n.link_url,
|
||||||
|
CASE
|
||||||
|
WHEN n.type = 'personal' THEN n.is_read
|
||||||
|
ELSE CASE WHEN nr.notification_id IS NOT NULL THEN 1 ELSE 0 END
|
||||||
|
END as is_read
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
|
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
|
||||||
${where}
|
${where}
|
||||||
ORDER BY n.is_pinned DESC, n.created_at DESC
|
ORDER BY n.is_pinned DESC, n.created_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
[...params, pSize, offset]
|
[req.userId, ...params, pSize, offset]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [countResult] = await pool.query(
|
const [countResult] = await pool.query(
|
||||||
@@ -67,16 +140,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
router.get('/pinned', async (req: AuthRequest, res: Response) => {
|
router.get('/pinned', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at,
|
`SELECT * FROM (
|
||||||
n.is_pinned, n.is_urgent, n.image_url, n.link_url
|
SELECT n.id, n.type, n.title, n.content, n.created_at,
|
||||||
|
n.is_pinned, n.is_urgent, n.image_url, n.link_url,
|
||||||
|
CASE
|
||||||
|
WHEN n.type = 'personal' THEN n.is_read
|
||||||
|
ELSE CASE WHEN nr.notification_id IS NOT NULL THEN 1 ELSE 0 END
|
||||||
|
END as is_read
|
||||||
FROM notifications n
|
FROM notifications n
|
||||||
|
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
|
||||||
WHERE (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
|
WHERE (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
|
||||||
AND n.is_urgent = 1 AND n.is_read = 0
|
AND n.is_urgent = 1
|
||||||
AND (n.expire_at IS NULL OR n.expire_at > NOW())
|
AND (n.expire_at IS NULL OR n.expire_at > NOW())
|
||||||
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
|
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
|
||||||
ORDER BY n.created_at DESC
|
) AS sub
|
||||||
|
WHERE is_read = 0
|
||||||
|
ORDER BY created_at DESC
|
||||||
LIMIT 5`,
|
LIMIT 5`,
|
||||||
[req.userId, req.userId]
|
[req.userId, req.userId, req.userId]
|
||||||
)
|
)
|
||||||
res.json({ code: 0, data: rows })
|
res.json({ code: 0, data: rows })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -89,12 +170,19 @@ router.get('/pinned', async (req: AuthRequest, res: Response) => {
|
|||||||
router.get('/unread-count', async (req: AuthRequest, res: Response) => {
|
router.get('/unread-count', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT COUNT(*) as count FROM notifications
|
`SELECT COUNT(*) as count FROM (
|
||||||
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
|
SELECT n.id
|
||||||
AND is_read = 0
|
FROM notifications n
|
||||||
AND (expire_at IS NULL OR expire_at > NOW())
|
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
|
||||||
AND (publish_at IS NULL OR publish_at <= NOW())`,
|
WHERE (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
|
||||||
[req.userId, req.userId]
|
AND (n.expire_at IS NULL OR n.expire_at > NOW())
|
||||||
|
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
|
||||||
|
AND (
|
||||||
|
(n.type = 'personal' AND n.is_read = 0)
|
||||||
|
OR (n.type != 'personal' AND nr.notification_id IS NULL)
|
||||||
|
)
|
||||||
|
) as unread`,
|
||||||
|
[req.userId, req.userId, req.userId]
|
||||||
)
|
)
|
||||||
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
|
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -106,14 +194,29 @@ router.get('/unread-count', async (req: AuthRequest, res: Response) => {
|
|||||||
/** 标记单条已读 */
|
/** 标记单条已读 */
|
||||||
router.put('/:id/read', async (req: AuthRequest, res: Response) => {
|
router.put('/:id/read', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const [result] = await pool.query(
|
// 先检查通知是否存在且用户可见
|
||||||
`UPDATE notifications SET is_read = 1 WHERE id = ?
|
const [notiRows] = await pool.query(
|
||||||
AND (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))`,
|
`SELECT n.id, n.type, n.user_id FROM notifications n
|
||||||
|
WHERE n.id = ?
|
||||||
|
AND (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))`,
|
||||||
[req.params.id, req.userId, req.userId]
|
[req.params.id, req.userId, req.userId]
|
||||||
)
|
)
|
||||||
if ((result as any).affectedRows === 0) {
|
const notification = (notiRows as any[])[0]
|
||||||
|
if (!notification) {
|
||||||
return res.status(404).json({ code: 40400, message: '通知不存在' })
|
return res.status(404).json({ code: 40400, message: '通知不存在' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (notification.type === 'personal' && notification.user_id === req.userId) {
|
||||||
|
// 个人通知:直接更新 is_read 字段
|
||||||
|
await pool.query('UPDATE notifications SET is_read = 1 WHERE id = ?', [req.params.id])
|
||||||
|
} else {
|
||||||
|
// 系统/群组公告:插入 notification_reads 记录(IGNORE 避免重复)
|
||||||
|
await pool.query(
|
||||||
|
'INSERT IGNORE INTO notification_reads (notification_id, user_id) VALUES (?, ?)',
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ code: 0 })
|
res.json({ code: 0 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Notification] mark-read error:', err)
|
console.error('[Notification] mark-read error:', err)
|
||||||
@@ -123,21 +226,72 @@ router.put('/:id/read', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
/** 全部标记已读 */
|
/** 全部标记已读 */
|
||||||
router.put('/read-all', async (req: AuthRequest, res: Response) => {
|
router.put('/read-all', async (req: AuthRequest, res: Response) => {
|
||||||
|
const conn = await pool.getConnection()
|
||||||
try {
|
try {
|
||||||
await pool.query(
|
await conn.beginTransaction()
|
||||||
|
|
||||||
|
// 1. 标记所有个人通知为已读
|
||||||
|
await conn.query(
|
||||||
`UPDATE notifications SET is_read = 1
|
`UPDATE notifications SET is_read = 1
|
||||||
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
|
WHERE user_id = ? AND type = 'personal' AND is_read = 0
|
||||||
AND is_read = 0
|
|
||||||
AND (expire_at IS NULL OR expire_at > NOW())
|
AND (expire_at IS NULL OR expire_at > NOW())
|
||||||
AND (publish_at IS NULL OR publish_at <= NOW())`,
|
AND (publish_at IS NULL OR publish_at <= NOW())`,
|
||||||
[req.userId, req.userId]
|
[req.userId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 2. 为所有未读的系统/群组公告插入已读记录
|
||||||
|
await conn.query(
|
||||||
|
`INSERT IGNORE INTO notification_reads (notification_id, user_id)
|
||||||
|
SELECT n.id, ?
|
||||||
|
FROM notifications n
|
||||||
|
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
|
||||||
|
WHERE ((n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
|
||||||
|
AND (n.expire_at IS NULL OR n.expire_at > NOW())
|
||||||
|
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
|
||||||
|
AND nr.notification_id IS NULL`,
|
||||||
|
[req.userId, req.userId, req.userId]
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
res.json({ code: 0 })
|
res.json({ code: 0 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
await conn.rollback()
|
||||||
console.error('[Notification] read-all error:', err)
|
console.error('[Notification] read-all error:', err)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
} finally {
|
||||||
|
conn.release()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 上传通知图片(管理员) */
|
||||||
|
router.post('/upload-image', requireAdmin, (req: AuthRequest, res: Response) => {
|
||||||
|
upload.single('file')(req, res, async (err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(400).json({ code: 40001, message: err.message })
|
||||||
|
}
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请选择图片文件' })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json({ code: 0, data: { image_url: req.file.filename } })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] image upload error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 验证 URL 安全性(防止 javascript: 等危险协议) */
|
||||||
|
function isValidUrl(url: string): boolean {
|
||||||
|
if (!url) return true // 空值允许
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return ['http:', 'https:'].includes(parsed.protocol)
|
||||||
|
} catch {
|
||||||
|
// 非标准 URL 格式,小程序不支持相对路径,拒绝
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 发布公告(管理员/群主) */
|
/** 发布公告(管理员/群主) */
|
||||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
@@ -150,6 +304,12 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
|||||||
if (title.length > 100) {
|
if (title.length > 100) {
|
||||||
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
|
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
|
||||||
}
|
}
|
||||||
|
if (content && typeof content === 'string' && content.length > 5000) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '内容不能超过5000字' })
|
||||||
|
}
|
||||||
|
if (link_url && !isValidUrl(link_url)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '链接格式无效' })
|
||||||
|
}
|
||||||
|
|
||||||
// 系统公告需要管理员权限
|
// 系统公告需要管理员权限
|
||||||
if (type === 'system') {
|
if (type === 'system') {
|
||||||
@@ -160,8 +320,11 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 群组公告需要群主权限
|
// 群组公告需要 group_id 且需要群主权限
|
||||||
if (type === 'group' && group_id) {
|
if (type === 'group') {
|
||||||
|
if (!group_id || typeof group_id !== 'number') {
|
||||||
|
return res.status(400).json({ code: 40001, message: '群组公告必须指定群组' })
|
||||||
|
}
|
||||||
const [groupRows] = await pool.query('SELECT created_by FROM `groups` WHERE id = ?', [group_id])
|
const [groupRows] = await pool.query('SELECT created_by FROM `groups` WHERE id = ?', [group_id])
|
||||||
const group = (groupRows as any[])[0]
|
const group = (groupRows as any[])[0]
|
||||||
if (!group || group.created_by !== req.userId) {
|
if (!group || group.created_by !== req.userId) {
|
||||||
@@ -194,11 +357,25 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 编辑公告(管理员) */
|
/** 编辑公告(管理员或群主) */
|
||||||
router.put('/:id', requireAdmin, async (req: AuthRequest, res: Response) => {
|
router.put('/:id', requireNotificationEditAuth, async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { title, content, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url } = req.body
|
const { title, content, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url } = req.body
|
||||||
|
|
||||||
|
// 验证字段
|
||||||
|
if (title !== undefined && (typeof title !== 'string' || title.trim().length === 0)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '标题不能为空' })
|
||||||
|
}
|
||||||
|
if (title && title.length > 100) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
|
||||||
|
}
|
||||||
|
if (content && typeof content === 'string' && content.length > 5000) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '内容不能超过5000字' })
|
||||||
|
}
|
||||||
|
if (link_url && !isValidUrl(link_url)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '链接格式无效' })
|
||||||
|
}
|
||||||
|
|
||||||
const updates: string[] = []
|
const updates: string[] = []
|
||||||
const params: any[] = []
|
const params: any[] = []
|
||||||
|
|
||||||
|
|||||||
194
server/src/routes/recurring.ts
Normal file
194
server/src/routes/recurring.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { authMiddleware } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
router.use(authMiddleware)
|
||||||
|
|
||||||
|
/** 获取用户的周期模板列表 */
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
`SELECT rt.*, c.name as category_name, c.icon as category_icon, c.color as category_color
|
||||||
|
FROM recurring_templates rt
|
||||||
|
LEFT JOIN categories c ON rt.category_id = c.id
|
||||||
|
WHERE rt.user_id = ?
|
||||||
|
ORDER BY rt.is_active DESC, rt.next_date ASC`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
res.json({ code: 0, data: rows })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get recurring templates error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '获取失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 创建周期模板 */
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId
|
||||||
|
const { amount, type, category_id, note, frequency, next_date, end_date, group_id } = req.body
|
||||||
|
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请输入金额' })
|
||||||
|
}
|
||||||
|
if (!type || !['expense', 'income'].includes(type)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请选择类型' })
|
||||||
|
}
|
||||||
|
if (!category_id) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请选择分类' })
|
||||||
|
}
|
||||||
|
if (!frequency || !['weekly', 'monthly', 'yearly'].includes(frequency)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请选择周期' })
|
||||||
|
}
|
||||||
|
if (!next_date) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '请选择开始日期' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`INSERT INTO recurring_templates (user_id, amount, type, category_id, note, frequency, next_date, end_date, group_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[userId, amount, type, category_id, note || '', frequency, next_date, end_date || null, group_id || null]
|
||||||
|
)
|
||||||
|
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create recurring template error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '创建失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 更新周期模板 */
|
||||||
|
router.put('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId
|
||||||
|
const { id } = req.params
|
||||||
|
const { amount, type, category_id, note, frequency, next_date, end_date, is_active } = req.body
|
||||||
|
|
||||||
|
// 校验所有权
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?',
|
||||||
|
[id, userId]
|
||||||
|
)
|
||||||
|
if ((rows as any[]).length === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '模板不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = []
|
||||||
|
const params: any[] = []
|
||||||
|
|
||||||
|
if (amount !== undefined) { updates.push('amount = ?'); params.push(amount) }
|
||||||
|
if (type !== undefined) { updates.push('type = ?'); params.push(type) }
|
||||||
|
if (category_id !== undefined) { updates.push('category_id = ?'); params.push(category_id) }
|
||||||
|
if (note !== undefined) { updates.push('note = ?'); params.push(note) }
|
||||||
|
if (frequency !== undefined) { updates.push('frequency = ?'); params.push(frequency) }
|
||||||
|
if (next_date !== undefined) { updates.push('next_date = ?'); params.push(next_date) }
|
||||||
|
if (end_date !== undefined) { updates.push('end_date = ?'); params.push(end_date) }
|
||||||
|
if (is_active !== undefined) { updates.push('is_active = ?'); params.push(is_active) }
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '无更新字段' })
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id, userId)
|
||||||
|
await pool.execute(
|
||||||
|
`UPDATE recurring_templates SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update recurring template error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '更新失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 删除周期模板 */
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId
|
||||||
|
const { id } = req.params
|
||||||
|
|
||||||
|
const [rows] = await pool.execute(
|
||||||
|
'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?',
|
||||||
|
[id, userId]
|
||||||
|
)
|
||||||
|
if ((rows as any[]).length === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '模板不存在' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
'DELETE FROM recurring_templates WHERE id = ? AND user_id = ?',
|
||||||
|
[id, userId]
|
||||||
|
)
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete recurring template error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '删除失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 同步:检查所有活跃模板,生成到期交易 */
|
||||||
|
router.post('/sync', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = (req as any).userId
|
||||||
|
// 获取该用户所有活跃且到期(next_date <= 今天)的模板
|
||||||
|
const [templates] = await pool.execute(
|
||||||
|
`SELECT * FROM recurring_templates
|
||||||
|
WHERE user_id = ? AND is_active = 1 AND next_date <= CURDATE()
|
||||||
|
ORDER BY next_date ASC`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
|
||||||
|
let generated = 0
|
||||||
|
const today = new Date()
|
||||||
|
const todayStr = today.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
for (const t of templates as any[]) {
|
||||||
|
let next = new Date(t.next_date)
|
||||||
|
const end = t.end_date ? new Date(t.end_date) : null
|
||||||
|
|
||||||
|
// 生成所有错过的交易
|
||||||
|
while (next <= today) {
|
||||||
|
if (end && next > end) break
|
||||||
|
|
||||||
|
// 检查是否已生成过(避免重复)
|
||||||
|
const [existing] = await pool.execute(
|
||||||
|
`SELECT id FROM transactions
|
||||||
|
WHERE user_id = ? AND recurring_id = ? AND date = ?`,
|
||||||
|
[userId, t.id, next.toISOString().slice(0, 10)]
|
||||||
|
)
|
||||||
|
if ((existing as any[]).length === 0) {
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id, recurring_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[userId, t.amount, t.type, t.category_id, t.note,
|
||||||
|
next.toISOString().slice(0, 10), t.group_id, t.id]
|
||||||
|
)
|
||||||
|
generated++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推进到下一周期
|
||||||
|
if (t.frequency === 'weekly') {
|
||||||
|
next = new Date(next.getTime() + 7 * 86400000)
|
||||||
|
} else if (t.frequency === 'monthly') {
|
||||||
|
next = new Date(next.getFullYear(), next.getMonth() + 1, next.getDate())
|
||||||
|
} else if (t.frequency === 'yearly') {
|
||||||
|
next = new Date(next.getFullYear() + 1, next.getMonth(), next.getDate())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 next_date:循环结束后 next 已推进到今天之后的下一周期,直接使用
|
||||||
|
await pool.execute(
|
||||||
|
'UPDATE recurring_templates SET next_date = ? WHERE id = ?',
|
||||||
|
[next.toISOString().slice(0, 10), t.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ code: 0, data: { generated } })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sync recurring error:', error)
|
||||||
|
res.status(500).json({ code: 50000, message: '同步失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -22,8 +22,9 @@ async function buildWhereClause(
|
|||||||
)
|
)
|
||||||
if ((memberCheck as any[]).length === 0) return null
|
if ((memberCheck as any[]).length === 0) return null
|
||||||
|
|
||||||
params.push(groupId, groupId)
|
// 群组视图:统计所有群组成员的个人账单
|
||||||
let where = `WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
|
params.push(groupId)
|
||||||
|
let where = `WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
|
||||||
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
|
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
|
||||||
return { where, params }
|
return { where, params }
|
||||||
}
|
}
|
||||||
@@ -123,7 +124,7 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
|
|||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN categories c ON t.category_id = c.id
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
${result.where}
|
${result.where}
|
||||||
GROUP BY c.id
|
GROUP BY c.id, c.name, c.icon, c.color
|
||||||
ORDER BY amount DESC`,
|
ORDER BY amount DESC`,
|
||||||
[...result.params, type, startDate, endDate]
|
[...result.params, type, startDate, endDate]
|
||||||
)
|
)
|
||||||
@@ -169,4 +170,99 @@ router.get('/trend', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 智能分类建议:根据历史记录的金额和备注推荐分类 */
|
||||||
|
router.get('/suggest-category', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId
|
||||||
|
const { amount, type = 'expense', note = '' } = req.query
|
||||||
|
|
||||||
|
if (!amount || !['expense', 'income'].includes(type as string)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '参数错误' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const amt = parseInt(amount as string)
|
||||||
|
const noteStr = (note as string).trim()
|
||||||
|
|
||||||
|
// 1. 关键词匹配:从备注中提取关键词,查找历史记录中最常用的分类
|
||||||
|
let suggestedCategory: any = null
|
||||||
|
let confidence = 0
|
||||||
|
|
||||||
|
if (noteStr) {
|
||||||
|
// 提取关键词(取前4个字符以上的词)
|
||||||
|
const keywords = noteStr.replace(/[,,。.!!??\s]+/g, ' ').split(' ').filter(w => w.length >= 1)
|
||||||
|
if (keywords.length > 0) {
|
||||||
|
const likeClauses = keywords.map(() => 't.note LIKE ?').join(' OR ')
|
||||||
|
const likeParams = keywords.map(k => `%${k}%`)
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT t.category_id, c.name, c.color, COUNT(*) as cnt
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
WHERE t.user_id = ? AND t.type = ? AND (${likeClauses})
|
||||||
|
GROUP BY t.category_id, c.name, c.color
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[userId, type, ...likeParams]
|
||||||
|
)
|
||||||
|
const match = (rows as any[])[0]
|
||||||
|
if (match) {
|
||||||
|
suggestedCategory = { id: match.category_id, name: match.name, color: match.color }
|
||||||
|
// 置信度基于匹配次数
|
||||||
|
confidence = Math.min(90, 40 + match.cnt * 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 金额相近匹配(±30%)
|
||||||
|
if (!suggestedCategory) {
|
||||||
|
const minAmt = Math.floor(amt * 0.7)
|
||||||
|
const maxAmt = Math.ceil(amt * 1.3)
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT t.category_id, c.name, c.color, COUNT(*) as cnt
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
WHERE t.user_id = ? AND t.type = ? AND t.amount BETWEEN ? AND ?
|
||||||
|
GROUP BY t.category_id, c.name, c.color
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[userId, type, minAmt, maxAmt]
|
||||||
|
)
|
||||||
|
const match = (rows as any[])[0]
|
||||||
|
if (match) {
|
||||||
|
suggestedCategory = { id: match.category_id, name: match.name, color: match.color }
|
||||||
|
confidence = Math.min(70, 20 + match.cnt * 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 最近使用的分类(兜底)
|
||||||
|
if (!suggestedCategory) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT t.category_id, c.name, c.color
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
WHERE t.user_id = ? AND t.type = ?
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[userId, type]
|
||||||
|
)
|
||||||
|
const match = (rows as any[])[0]
|
||||||
|
if (match) {
|
||||||
|
suggestedCategory = { id: match.category_id, name: match.name, color: match.color }
|
||||||
|
confidence = 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
category: suggestedCategory,
|
||||||
|
confidence
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Stats] suggest-category error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
34
server/src/routes/track.ts
Normal file
34
server/src/routes/track.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
/** 接收前端埋点数据 */
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { events } = req.body
|
||||||
|
const userId = (req as any).userId || null
|
||||||
|
|
||||||
|
if (!Array.isArray(events)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '无效的埋点数据' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量插入埋点事件(限制每个事件的字段长度)
|
||||||
|
for (const event of events.slice(0, 50)) { // 最多 50 个事件
|
||||||
|
const eventType = typeof event.event === 'string' ? event.event.slice(0, 50) : 'unknown'
|
||||||
|
const page = typeof event.page === 'string' ? event.page.slice(0, 200) : ''
|
||||||
|
const data = event.data && typeof event.data === 'object' ? event.data : {}
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
'INSERT INTO track_events (event, page, data, user_id) VALUES (?, ?, ?, ?)',
|
||||||
|
[eventType, page, JSON.stringify(data), userId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch {
|
||||||
|
res.json({ code: 0 }) // 埋点失败不影响业务
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -15,6 +15,7 @@ function safeInt(val: any, fallback: number, min: number, max: number): number {
|
|||||||
|
|
||||||
router.get('/:id', async (req: AuthRequest, res: Response) => {
|
router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
// 用户可以查看:自己的记录 + 自己所在群组成员的所有记录
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
|
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
|
||||||
c.name as category_name, c.icon as category_icon, c.color as category_color,
|
c.name as category_name, c.icon as category_icon, c.color as category_color,
|
||||||
@@ -24,11 +25,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
|||||||
LEFT JOIN users u ON t.user_id = u.id
|
LEFT JOIN users u ON t.user_id = u.id
|
||||||
WHERE t.id = ? AND (
|
WHERE t.id = ? AND (
|
||||||
t.user_id = ?
|
t.user_id = ?
|
||||||
OR t.user_id IN (
|
OR t.user_id IN (SELECT user_id FROM group_members WHERE group_id IN (SELECT group_id FROM group_members WHERE user_id = ?))
|
||||||
SELECT gm2.user_id FROM group_members gm1
|
|
||||||
JOIN group_members gm2 ON gm1.group_id = gm2.group_id
|
|
||||||
WHERE gm1.user_id = ?
|
|
||||||
)
|
|
||||||
)`,
|
)`,
|
||||||
[req.params.id, req.userId, req.userId]
|
[req.params.id, req.userId, req.userId]
|
||||||
)
|
)
|
||||||
@@ -54,7 +51,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
const params: any[] = []
|
const params: any[] = []
|
||||||
|
|
||||||
if (group_id && group_id !== 'null') {
|
if (group_id && group_id !== 'null') {
|
||||||
// 群组视图:群组下所有成员的记录
|
// 群组视图:验证用户是群组成员,然后显示所有群组成员的记录
|
||||||
const [memberCheck] = await pool.query(
|
const [memberCheck] = await pool.query(
|
||||||
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
|
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||||
[group_id, req.userId]
|
[group_id, req.userId]
|
||||||
@@ -62,8 +59,9 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
if ((memberCheck as any[]).length === 0) {
|
if ((memberCheck as any[]).length === 0) {
|
||||||
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||||
}
|
}
|
||||||
where = 'WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
|
// 群组账单 = 所有群组成员的个人账单
|
||||||
params.push(group_id, group_id)
|
where = 'WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
|
||||||
|
params.push(group_id)
|
||||||
} else {
|
} else {
|
||||||
// 个人视图:我的所有记录(不管 group_id)
|
// 个人视图:我的所有记录(不管 group_id)
|
||||||
where = 'WHERE t.user_id = ?'
|
where = 'WHERE t.user_id = ?'
|
||||||
@@ -102,7 +100,9 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
||||||
where += ' AND t.note LIKE ?'; params.push(`%${keyword.trim()}%`)
|
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
|
||||||
|
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
|
||||||
|
where += ' AND t.note LIKE ?'; params.push(`%${escapedKeyword}%`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
|
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const upload = multer({
|
|||||||
router.get('/me', async (req: AuthRequest, res: Response) => {
|
router.get('/me', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
'SELECT id, nickname, avatar_url, role, created_at FROM users WHERE id = ?',
|
'SELECT id, nickname, avatar_url, slogan, role, created_at FROM users WHERE id = ?',
|
||||||
[req.userId]
|
[req.userId]
|
||||||
)
|
)
|
||||||
const user = (rows as any[])[0]
|
const user = (rows as any[])[0]
|
||||||
@@ -62,10 +62,10 @@ router.get('/me', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 更新用户昵称 */
|
/** 更新用户昵称/签名 */
|
||||||
router.put('/me', async (req: AuthRequest, res: Response) => {
|
router.put('/me', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { nickname } = req.body
|
const { nickname, slogan } = req.body
|
||||||
|
|
||||||
if (nickname !== undefined) {
|
if (nickname !== undefined) {
|
||||||
if (typeof nickname !== 'string' || nickname.trim().length === 0) {
|
if (typeof nickname !== 'string' || nickname.trim().length === 0) {
|
||||||
@@ -76,6 +76,15 @@ router.put('/me', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (slogan !== undefined) {
|
||||||
|
if (typeof slogan !== 'string') {
|
||||||
|
return res.status(400).json({ code: 40001, message: '签名格式错误' })
|
||||||
|
}
|
||||||
|
if (slogan.length > 100) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '签名不能超过100个字符' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updates: string[] = []
|
const updates: string[] = []
|
||||||
const params: any[] = []
|
const params: any[] = []
|
||||||
|
|
||||||
@@ -84,6 +93,11 @@ router.put('/me', async (req: AuthRequest, res: Response) => {
|
|||||||
params.push(nickname.trim())
|
params.push(nickname.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (slogan !== undefined) {
|
||||||
|
updates.push('slogan = ?')
|
||||||
|
params.push(slogan.trim())
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
|
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
|
||||||
}
|
}
|
||||||
@@ -118,7 +132,11 @@ router.post('/avatar', (req: AuthRequest, res: Response) => {
|
|||||||
if (oldUrl) {
|
if (oldUrl) {
|
||||||
const oldPath = path.join(AVATAR_DIR, oldUrl)
|
const oldPath = path.join(AVATAR_DIR, oldUrl)
|
||||||
if (fs.existsSync(oldPath)) {
|
if (fs.existsSync(oldPath)) {
|
||||||
|
try {
|
||||||
fs.unlinkSync(oldPath)
|
fs.unlinkSync(oldPath)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[User] 删除旧头像失败:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ const gzipAsync = promisify(zlib.gzip)
|
|||||||
|
|
||||||
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
||||||
|
|
||||||
|
// 分页查询大小,避免一次性加载大表到内存
|
||||||
|
const QUERY_BATCH_SIZE = 1000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 Node.js mysql2 备份数据库(不依赖 mysqldump)
|
* 使用 Node.js mysql2 备份数据库(不依赖 mysqldump)
|
||||||
* 导出所有表的 INSERT 语句,gzip 压缩存储
|
* 导出所有表的 INSERT 语句,gzip 压缩存储
|
||||||
|
* 使用分页查询避免大表内存问题
|
||||||
*/
|
*/
|
||||||
export async function backupDatabase(): Promise<string> {
|
export async function backupDatabase(): Promise<string> {
|
||||||
if (!fs.existsSync(BACKUP_DIR)) {
|
if (!fs.existsSync(BACKUP_DIR)) {
|
||||||
@@ -35,31 +39,40 @@ export async function backupDatabase(): Promise<string> {
|
|||||||
lines.push(`-- 表: ${tableName}`)
|
lines.push(`-- 表: ${tableName}`)
|
||||||
lines.push(`-- ----------------------------`)
|
lines.push(`-- ----------------------------`)
|
||||||
|
|
||||||
// 获取表数据
|
// 获取表总行数
|
||||||
const [rows] = await pool.query(`SELECT * FROM \`${tableName}\``)
|
const [countResult] = await pool.query(`SELECT COUNT(*) as count FROM \`${tableName}\``)
|
||||||
const data = rows as any[]
|
const totalRows = (countResult as any[])[0].count
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (totalRows === 0) {
|
||||||
lines.push(`-- ${tableName}: 无数据`)
|
lines.push(`-- ${tableName}: 无数据`)
|
||||||
lines.push('')
|
lines.push('')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取列名
|
// 获取列名(从第一行)
|
||||||
const columns = Object.keys(data[0])
|
const [firstRow] = await pool.query(`SELECT * FROM \`${tableName}\` LIMIT 1`)
|
||||||
|
const columns = Object.keys((firstRow as any[])[0])
|
||||||
const colList = columns.map(c => `\`${c}\``).join(', ')
|
const colList = columns.map(c => `\`${c}\``).join(', ')
|
||||||
|
|
||||||
// 生成 INSERT 语句(每 100 行一批)
|
// 分页查询并生成 INSERT 语句
|
||||||
const BATCH_SIZE = 100
|
let offset = 0
|
||||||
for (let i = 0; i < data.length; i += BATCH_SIZE) {
|
while (offset < totalRows) {
|
||||||
const batch = data.slice(i, i + BATCH_SIZE)
|
const [rows] = await pool.query(
|
||||||
|
`SELECT * FROM \`${tableName}\` LIMIT ? OFFSET ?`,
|
||||||
|
[QUERY_BATCH_SIZE, offset]
|
||||||
|
)
|
||||||
|
const data = rows as any[]
|
||||||
|
|
||||||
|
// 每 100 行生成一条 INSERT
|
||||||
|
const INSERT_BATCH = 100
|
||||||
|
for (let i = 0; i < data.length; i += INSERT_BATCH) {
|
||||||
|
const batch = data.slice(i, i + INSERT_BATCH)
|
||||||
const values = batch.map(row => {
|
const values = batch.map(row => {
|
||||||
const vals = columns.map(col => {
|
const vals = columns.map(col => {
|
||||||
const v = row[col]
|
const v = row[col]
|
||||||
if (v === null) return 'NULL'
|
if (v === null) return 'NULL'
|
||||||
if (typeof v === 'number') return String(v)
|
if (typeof v === 'number') return String(v)
|
||||||
if (typeof v === 'boolean') return v ? '1' : '0'
|
if (typeof v === 'boolean') return v ? '1' : '0'
|
||||||
// mysql2 escape 处理所有字符串/日期转义(自带引号)
|
|
||||||
return pool.escape(v)
|
return pool.escape(v)
|
||||||
})
|
})
|
||||||
return `(${vals.join(', ')})`
|
return `(${vals.join(', ')})`
|
||||||
@@ -68,6 +81,9 @@ export async function backupDatabase(): Promise<string> {
|
|||||||
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
|
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
|
||||||
lines.push(` ${values};`)
|
lines.push(` ${values};`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offset += QUERY_BATCH_SIZE
|
||||||
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user