Compare commits

..

37 Commits

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

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

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

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

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

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

文档:
- CLAUDE.md 更新项目结构
- DEV.md 添加埋点和日志说明
2026-06-08 14:28:11 +08:00
48 changed files with 4676 additions and 333 deletions

View File

@@ -110,13 +110,16 @@ client/src/
│ ├── format.ts # 金额/日期格式化
│ ├── request.ts # HTTP 请求封装 (401 自动重登录+队列重试)
│ ├── system.ts # 系统信息 (statusBarHeight, capsuleRight)
── app-ready.ts # App 启动就绪机制 (waitForReady)
── app-ready.ts # App 启动就绪机制 (waitForReady)
│ └── tracker.ts # 前端埋点 (页面访问/操作/错误)
├── config.ts # 全局配置 (API_BASE)
└── static/icons/ # tabBar 图标 (PNG)
server/src/
├── index.ts # Express 入口 (CORS, JSON, 静态文件, 路由注册)
├── middleware/auth.ts # HMAC token 验证 (timingSafeEqual)
├── middleware/
│ ├── auth.ts # HMAC token 验证 (timingSafeEqual)
│ └── logger.ts # 请求日志 (记录到 logs/ 目录)
├── routes/
│ ├── auth.ts # 登录 / token 签发 (返回用户信息)
│ ├── transaction.ts # 交易 CRUD (支持 group_id 视图切换)
@@ -130,6 +133,7 @@ server/src/
│ ├── feedback.ts # 用户反馈 (提交/查询/处理)
│ ├── config.ts # 系统配置 (公开读/管理员写)
│ ├── admin.ts # 管理后台 (仪表盘/用户管理)
│ ├── track.ts # 埋点数据接收
│ └── backup.ts # 备份 API
├── utils/
│ ├── backup.ts # Node.js 数据库备份 (gzip)

13
DEV.md
View File

@@ -75,6 +75,18 @@ res.status(500).json({ code: 50000, message: '服务器错误' })
**原因**:前端 `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
@@ -107,6 +119,7 @@ onPullDownRefresh(async () => {
- Never 使用 git stash
- Never 切换分支
- Never 过度封装,保持代码简洁明了
- Never 未确定需求就开始实现
## Requirements

View File

@@ -5,6 +5,7 @@ import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { saveLoginResult } from '@/utils/request'
import { markReady } from '@/utils/app-ready'
import { setupErrorTracking, trackAction, trackError } from '@/utils/tracker'
/** 登录重试最多3次 */
async function loginWithRetry(loginFn: () => Promise<any>, retries = 3): Promise<boolean> {
@@ -15,6 +16,7 @@ async function loginWithRetry(loginFn: () => Promise<any>, retries = 3): Promise
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))
@@ -25,6 +27,10 @@ async function loginWithRetry(loginFn: () => Promise<any>, retries = 3): Promise
}
onLaunch(() => {
// 初始化错误追踪
setupErrorTracking()
trackAction('app_launch')
const groupStore = useGroupStore()
const configStore = useConfigStore()
groupStore.init()

View File

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

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

@@ -0,0 +1,91 @@
import { request } from '@/utils/request'
/** 日志文件信息 */
export interface LogFileInfo {
name: string
date: string
size: number
modified: string
}
/** 日志统计 */
export interface LogStats {
today: {
total: number
errors: number
slow: number
warns: number
}
daily: Array<{
date: string
total: number
errors: number
slow: number
}>
}
/** 埋点统计 */
export interface TrackStats {
today: Array<{ event: string; count: number }>
daily: Array<{ date: string; count: number }>
events: Array<{ event: string; count: number }>
}
/** 埋点事件 */
export interface TrackEvent {
id: number
event: string
page: string
data: any
user_id: number | null
nickname: string | null
created_at: string
}
/** 获取日志文件列表 */
export function getLogFiles(): Promise<LogFileInfo[]> {
return request({ url: '/logs/files' })
}
/** 获取日志统计 */
export function getLogStats(): Promise<LogStats> {
return request({ url: '/logs/stats' })
}
/** 查询日志 */
export function queryLogs(params: {
date: string
level?: string
keyword?: string
page?: number
pageSize?: number
}): Promise<{ lines: string[]; total: number; page: number; pageSize: number }> {
const { date, ...rest } = params
// 过滤掉 undefined 值,防止被序列化为 "undefined" 字符串
const queryParams: Record<string, any> = {}
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined) queryParams[key] = value
}
return request({ url: `/logs/${date}`, data: queryParams })
}
/** 获取埋点统计 */
export function getTrackStats(): Promise<TrackStats> {
return request({ url: '/logs/track/stats' })
}
/** 查询埋点事件 */
export function queryTrackEvents(params?: {
event?: string
page?: number
pageSize?: number
}): Promise<{ list: TrackEvent[]; total: number; page: number; pageSize: number }> {
// 过滤掉 undefined 值
const queryParams: Record<string, any> = {}
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) queryParams[key] = value
}
}
return request({ url: '/logs/track/list', data: queryParams })
}

View File

@@ -0,0 +1,64 @@
import { request } from '@/utils/request'
/** 周期模板 */
export interface RecurringTemplate {
id: number
user_id: number
amount: number
type: 'expense' | 'income'
category_id: number
note: string
frequency: 'weekly' | 'monthly' | 'yearly'
next_date: string
end_date: string | null
is_active: number
group_id: number | null
category_name?: string
category_icon?: string
category_color?: string
created_at: string
updated_at: string
}
/** 获取周期模板列表 */
export function getRecurringTemplates() {
return request<RecurringTemplate[]>({ url: '/recurring' })
}
/** 创建周期模板 */
export function createRecurringTemplate(data: {
amount: number
type: 'expense' | 'income'
category_id: number
note?: string
frequency: 'weekly' | 'monthly' | 'yearly'
next_date: string
end_date?: string | null
group_id?: number | null
}) {
return request<{ id: number }>({ url: '/recurring', method: 'POST', data })
}
/** 更新周期模板 */
export function updateRecurringTemplate(id: number, data: {
amount?: number
type?: 'expense' | 'income'
category_id?: number
note?: string
frequency?: 'weekly' | 'monthly' | 'yearly'
next_date?: string
end_date?: string | null
is_active?: number
}) {
return request({ url: `/recurring/${id}`, method: 'PUT', data })
}
/** 删除周期模板 */
export function deleteRecurringTemplate(id: number) {
return request({ url: `/recurring/${id}`, method: 'DELETE' })
}
/** 同步周期账单(生成到期交易) */
export function syncRecurring() {
return request<{ generated: number }>({ url: '/recurring/sync', method: 'POST' })
}

View File

@@ -41,3 +41,14 @@ export function getCategoryStats(month?: string, type: string = 'expense', group
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) {
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } })
}
/** 智能分类建议结果 */
export interface CategorySuggestion {
category: { id: number; name: string; color: string } | null
confidence: number
}
/** 根据金额和备注推荐分类 */
export function suggestCategory(amount: number, type: string, note?: string) {
return request<CategorySuggestion>({ url: '/stats/suggest-category', data: { amount, type, note } })
}

View File

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

View File

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

View File

@@ -1,9 +1,15 @@
<template>
<view class="numpad">
<view class="numpad" :class="{ 'numpad-fixed': fixed, 'numpad-hidden': !visible }">
<view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)">
<text class="key-text">{{ key }}</text>
</view>
<view class="key fn" @tap="onDelete">
<view
class="key fn"
@tap="onDeleteTap"
@touchstart="onDeleteTouchStart"
@touchend="onDeleteTouchEnd"
@touchcancel="onDeleteTouchEnd"
>
<view class="backspace-icon">
<view class="backspace-arrow"></view>
<view class="backspace-line"></view>
@@ -16,73 +22,118 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount } from 'vue'
import Icon from '@/components/Icon/Icon.vue'
import {
applyAmountKey,
clampCursorIndex,
type AmountKey
} from '@/components/AmountEditor/amount-edit'
const props = withDefaults(defineProps<{
/** 金额字符串v-model */
modelValue?: string
/** 光标位置 */
cursorIndex?: number
/** 最大金额(元) */
max?: number
/** 是否固定在底部(弹窗中使用时设为 false */
fixed?: boolean
/** 是否显示fixed 模式下控制滑入/滑出) */
visible?: boolean
}>(), {
modelValue: '',
max: 9999999.99
max: 9999999.99,
fixed: true,
visible: true
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'update:cursorIndex', index: number): void
(e: 'confirm'): void
}>()
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
const keys: AmountKey[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
const amountStr = computed(() => props.modelValue)
const effectiveCursorIndex = computed(() =>
clampCursorIndex(amountStr.value, props.cursorIndex ?? amountStr.value.length)
)
let deleteTimer: ReturnType<typeof setTimeout> | null = null
let deleteLongPressed = false
function vibrate() {
try { uni.vibrateShort({ type: 'light' }) } catch {}
}
function emitValue(val: string) {
emit('update:modelValue', val)
}
function onKeyTap(key: string) {
function applyKey(key: AmountKey | 'delete') {
vibrate()
const current = amountStr.value
if (key === '.') {
if (current.includes('.')) return
if (!current || current === '0') { emitValue('0.'); return }
}
let next: string
if (!current || current === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = current + key
}
if (next.includes('.')) {
const decimals = next.split('.')[1]
if (decimals && decimals.length > 2) return
}
if (next.length > 10) return
const num = parseFloat(next)
if (!isNaN(num) && num > props.max) return
emitValue(next)
}
function onDelete() {
vibrate()
const current = amountStr.value
if (current.length > 1) {
emitValue(current.slice(0, -1))
} else {
emitValue('')
const result = applyAmountKey(
amountStr.value,
effectiveCursorIndex.value,
key,
{ max: props.max }
)
if (result.accepted) {
emit('update:modelValue', result.value)
emit('update:cursorIndex', result.cursorIndex)
}
}
function onKeyTap(key: AmountKey) {
applyKey(key)
}
function onDeleteTap() {
if (deleteLongPressed) {
deleteLongPressed = false
return
}
applyKey('delete')
}
function doDeleteRepeat() {
deleteLongPressed = true
const result = applyAmountKey(
amountStr.value,
effectiveCursorIndex.value,
'delete',
{ max: props.max }
)
if (result.accepted) {
vibrate()
emit('update:modelValue', result.value)
emit('update:cursorIndex', result.cursorIndex)
deleteTimer = setTimeout(doDeleteRepeat, 120)
} else {
deleteLongPressed = false
deleteTimer = null
}
}
function onDeleteTouchStart() {
if (deleteTimer !== null) clearTimeout(deleteTimer)
deleteLongPressed = false
deleteTimer = setTimeout(doDeleteRepeat, 400)
}
function onDeleteTouchEnd() {
if (deleteTimer !== null) {
clearTimeout(deleteTimer)
deleteTimer = null
}
}
onBeforeUnmount(() => {
if (deleteTimer !== null) {
clearTimeout(deleteTimer)
deleteTimer = null
}
})
function onConfirm() {
vibrate()
emit('confirm')
@@ -96,18 +147,55 @@ function onConfirm() {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $space-sm;
padding: 0 $space-xl;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
padding: $space-sm $space-xl;
// 非 fixed 模式的显示/隐藏
&:not(.numpad-fixed) {
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s ease;
overflow: hidden;
max-height: 600rpx;
&.numpad-hidden {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
}
}
// fixed 模式的滑入/滑出
&.numpad-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: $bg;
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.numpad-hidden {
transform: translateY(100%);
}
}
}
.key {
height: 96rpx;
border-radius: $radius-xl;
background: $bg;
background: $surface;
border: 2rpx solid $border;
box-shadow: $shadow-clay;
@include flex-center;
transition: all $transition-fast;
-webkit-tap-highlight-color: transparent;
user-select: none;
&:active { background: $border; }
&:active {
background: $surface-warm;
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
}
.key-text {
@@ -119,13 +207,25 @@ function onConfirm() {
.key.fn {
background: $primary-light;
&:active { background: #FFD0C0; }
border-color: #FFD0C0;
&:active {
background: #FFD0C0;
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
}
.key.eq {
background: linear-gradient(135deg, $primary, $primary-dark);
box-shadow: 0 4rpx 16rpx rgba(255, 140, 105, 0.3);
&:active { background: $primary-dark; }
border: none;
box-shadow: $shadow-md, 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
&:active {
background: $primary-dark;
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
}
// 自绘 backspace 图标

View File

@@ -47,6 +47,13 @@
"enablePullDownRefresh": true
}
},
{
"path": "pages/recurring/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "周期账单"
}
},
{
"path": "pages/budget/index",
"style": {
@@ -131,6 +138,22 @@
"navigationBarTitleText": "系统配置",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin/logs",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "系统日志",
"enablePullDownRefresh": true
}
},
{
"path": "pages/admin/track",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "埋点统计",
"enablePullDownRefresh": true
}
}
],
"globalStyle": {

View File

@@ -19,16 +19,26 @@
</view>
</view>
<view class="amount-display">
<text class="currency">¥</text>
<text class="digits">{{ displayAmount }}</text>
<text class="cursor">|</text>
</view>
<AmountEditor
ref="amountEditorRef"
v-model="amountStr"
:fixed="true"
size="large"
:auto-focus="true"
@focus="keyboardVisible = true"
@blur="keyboardVisible = false"
@confirm="save"
/>
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat">
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat" @tap="blurAmountEditor">
<view class="category-grid">
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" @tap="selectedCat = cat.id">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id }" @tap="selectedCat = cat.id">
<view class="cat-icon-wrap">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
<view v-if="suggestedCatId === cat.id && selectedCat !== cat.id" class="suggest-badge">
<text class="suggest-badge-text">推荐</text>
</view>
</view>
<text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text>
</view>
</view>
@@ -36,18 +46,23 @@
<view class="extra-fields">
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
<view class="extra-row">
<view class="extra-row" @tap="blurAmountEditor">
<Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="extra-text">{{ dateLabel }}</text>
</view>
</picker>
<view class="extra-row">
<Icon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="blurAmountEditor" />
</view>
</view>
<Numpad v-model="amountStr" @confirm="save" />
<!-- 底部保存按钮键盘收起时显示 -->
<view class="save-bar" v-if="!showSuccess && !keyboardVisible">
<view class="save-btn" @tap="save">
<text class="save-btn-text">{{ editId ? '保存修改' : '保存' }}</text>
</view>
</view>
<SaveSuccess
v-model:visible="showSuccess"
@@ -65,7 +80,8 @@ import { onShow } from '@dcloudio/uni-app'
import { useCategoryStore } from '@/stores/category'
import { useTransactionStore } from '@/stores/transaction'
import { waitForReady } from '@/utils/app-ready'
import Numpad from '@/components/Numpad/Numpad.vue'
import { suggestCategory } from '@/api/stats'
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
@@ -78,6 +94,9 @@ const type = ref<'expense' | 'income'>('expense')
const amountStr = ref('0')
const selectedCat = ref<number | null>(null)
const note = ref('')
const suggestedCatId = ref<number | null>(null)
const suggestConfidence = ref(0)
let suggestTimer: ReturnType<typeof setTimeout> | null = null
const now = new Date()
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const selectedDate = ref(localToday)
@@ -86,6 +105,9 @@ const editId = ref<number | null>(null)
const showSuccess = ref(false)
const scrollToCat = ref('')
const todayStr = localToday
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const keyboardVisible = ref(false)
// Remember last selected category per type
const lastCatByType: Record<string, number | null> = { expense: null, income: null }
@@ -94,10 +116,6 @@ const dateLabel = computed(() => {
return `${d.getMonth() + 1}${d.getDate()}`
})
const displayAmount = computed(() => {
if (!amountStr.value) return ''
return amountStr.value
})
const currentCategories = computed(() => catStore.getByType(type.value))
@@ -121,6 +139,35 @@ watch(currentCategories, (cats) => {
}
})
// 智能分类推荐:监听金额和备注变化,防抖 600ms 后请求建议
watch([amountStr, note], () => {
if (suggestTimer) clearTimeout(suggestTimer)
suggestTimer = setTimeout(async () => {
const amt = Math.round(parseFloat(amountStr.value || '0') * 100)
if (amt <= 0 || editId.value) {
suggestedCatId.value = null
suggestConfidence.value = 0
return
}
try {
const result = await suggestCategory(amt, type.value, note.value)
if (result.category && result.confidence >= 30) {
suggestedCatId.value = result.category.id
suggestConfidence.value = result.confidence
// 置信度 >= 60 时自动选中
if (result.confidence >= 60 && !editId.value) {
selectedCat.value = result.category.id
}
} else {
suggestedCatId.value = null
suggestConfidence.value = 0
}
} catch {
suggestedCatId.value = null
}
}, 600)
})
onMounted(async () => {
await waitForReady()
@@ -136,7 +183,7 @@ onMounted(async () => {
}
const pages = getCurrentPages()
const page = pages[pages.length - 1] as any
const page = pages[pages.length - 1] as { options?: Record<string, string> }
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {
type.value = page.options.type
}
@@ -157,6 +204,7 @@ onMounted(async () => {
// 等待分类列表渲染完成后滚动到选中项
nextTick(() => {
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
amountEditorRef.value?.focus()
})
} else {
uni.showToast({ title: '记录不存在', icon: 'none' })
@@ -179,6 +227,10 @@ function onDateChange(e: any) {
selectedDate.value = e.detail.value
}
function blurAmountEditor() {
amountEditorRef.value?.blur()
}
async function save() {
if (saving.value) return
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
@@ -223,6 +275,7 @@ function onContinue() {
amountStr.value = ''
note.value = ''
showSuccess.value = false
nextTick(() => amountEditorRef.value?.focus())
}
/** 返回上一页 */
@@ -252,10 +305,51 @@ function goBack() {
@import '@/styles/mixins.scss';
.page {
@include page-base;
min-height: 100vh;
background: $bg;
display: flex;
flex-direction: column;
padding-bottom: 0;
// 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));
}
// 底部保存按钮栏
.save-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: $space-md $space-xl;
padding-bottom: calc(#{$space-md} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$space-md} + env(safe-area-inset-bottom));
background: $surface;
border-top: 2rpx solid $border;
z-index: 50;
}
.save-btn {
height: 96rpx;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-xl;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
&:active {
opacity: 0.8;
transform: scale(0.98);
}
}
.save-btn-text {
font-size: $font-xl;
font-weight: 600;
color: $surface;
}
.header-fixed {
@@ -320,38 +414,6 @@ function goBack() {
.income .slider { transform: translateX(160rpx); }
.amount-display {
text-align: center;
padding: $space-lg $space-xl $space-md;
}
.currency {
font-family: 'Fredoka', sans-serif;
font-size: $space-2xl;
font-weight: 500;
color: $text-muted;
margin-right: $space-xs;
}
.digits {
font-family: 'Fredoka', sans-serif;
font-size: 88rpx;
font-weight: 700;
color: $text;
font-variant-numeric: tabular-nums;
}
.cursor {
font-family: 'Fredoka', monospace;
font-size: 88rpx;
font-weight: 300;
color: $primary;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.category-scroll {
max-height: 320rpx;
@@ -370,8 +432,38 @@ function goBack() {
align-items: center;
gap: 6rpx;
padding: 12rpx 0;
position: relative;
&:active { transform: scale(0.95); }
&.suggested {
.cat-icon-wrap {
border: 3rpx dashed $primary;
border-radius: $radius-lg;
padding: 6rpx;
margin: -6rpx;
}
}
}
.cat-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; }
@@ -404,10 +496,6 @@ function goBack() {
.placeholder { color: $text-muted; }
@media (prefers-reduced-motion: reduce) {
.cursor {
animation: none;
opacity: 1;
}
}
</style>

View File

@@ -52,13 +52,13 @@
<!-- 操作按钮 -->
<view class="actions">
<view v-if="item.status === 'pending'" class="action-btn process" @tap="updateStatus(item, 'processed')">
<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="updateStatus(item, 'ignored')">
<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="updateStatus(item, 'pending')">
<view v-if="item.status !== 'pending'" class="action-btn pending" @tap.stop="updateStatus(item, 'pending')">
<text class="action-text">重新打开</text>
</view>
</view>
@@ -77,6 +77,39 @@
<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>
@@ -86,6 +119,7 @@ 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'
@@ -102,7 +136,7 @@ const statusMap: Record<string, string> = {
ignored: '已忽略'
}
const list = ref<any[]>([])
const list = ref<Feedback[]>([])
const loading = ref(false)
const filterStatus = ref('')
const page = ref(1)
@@ -110,6 +144,12 @@ 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 () => {
@@ -138,7 +178,7 @@ async function loadList(reset = false) {
}
loading.value = true
try {
const params: any = { page: page.value, pageSize }
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) {
@@ -160,7 +200,35 @@ function loadMore() {
loadList(false)
}
async function updateStatus(item: any, status: string) {
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
@@ -420,4 +488,128 @@ function goBack() { uni.navigateBack() }
font-size: $font-md;
color: $text-muted;
}
/* 回复弹窗 */
.reply-modal {
width: 100%;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
padding-bottom: calc($space-xl + env(safe-area-inset-bottom));
margin-top: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $space-lg $space-xl $space-md;
border-bottom: 2rpx solid $border;
}
.modal-title {
font-size: $font-xl;
font-weight: 600;
color: $text;
}
.modal-close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $border;
&:active { background: darken($border, 5%); }
}
.modal-close-text {
font-size: $font-2xl;
color: $text-sec;
line-height: 1;
}
.reply-body {
padding: $space-lg $space-xl;
}
.reply-origin {
padding: $space-md;
background: $bg;
border-radius: $radius-lg;
margin-bottom: $space-lg;
}
.reply-origin-label {
font-size: $font-sm;
color: $text-muted;
display: block;
margin-bottom: $space-xs;
}
.reply-origin-text {
font-size: $font-md;
color: $text-sec;
line-height: 1.6;
}
.reply-label {
font-size: $font-base;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-sm;
}
.reply-textarea {
width: 100%;
height: 200rpx;
padding: $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-md;
color: $text;
box-sizing: border-box;
}
.reply-footer {
display: flex;
gap: $space-md;
padding: $space-md $space-xl;
border-top: 2rpx solid $border;
}
.reply-btn {
flex: 1;
height: 80rpx;
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
&.skip {
background: $bg;
border: 2rpx solid $border;
}
&.confirm {
background: linear-gradient(135deg, $primary, #E67355);
}
&:active { opacity: 0.8; }
}
.reply-btn-text {
font-size: $font-md;
font-weight: 600;
.skip & { color: $text-sec; }
.confirm & { color: $surface; }
}
.modal-mask {
@include modal-mask;
}
</style>

View File

@@ -82,6 +82,16 @@
<text class="entry-text">系统配置</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="entry-item" @tap="goLogs">
<Icon name="edit" :size="32" color="#FF8C69" />
<text class="entry-text">系统日志</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="entry-item" @tap="goTrack">
<Icon name="edit" :size="32" color="#FF8C69" />
<text class="entry-text">埋点统计</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
</view>
</view>
@@ -100,21 +110,11 @@ import type { Dashboard } from '@/api/admin'
const loading = ref(true)
const dashboard = ref<Dashboard | null>(null)
// 仪表盘缓存5分钟内不重复请求
let dashboardCache: { data: Dashboard; time: number } | null = null
const CACHE_TTL = 5 * 60 * 1000
onMounted(async () => {
await waitForReady()
try {
// 检查缓存是否有效
if (dashboardCache && Date.now() - dashboardCache.time < CACHE_TTL) {
dashboard.value = dashboardCache.data
} else {
dashboard.value = await getDashboard()
dashboardCache = { data: dashboard.value, time: Date.now() }
}
dashboard.value = await getDashboard()
} catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally {
@@ -122,13 +122,10 @@ onMounted(async () => {
}
})
// 静默刷新(强制刷新缓存)
async function refreshDashboard(force = false) {
// 静默刷新
async function refreshDashboard() {
try {
if (force || !dashboardCache || Date.now() - dashboardCache.time >= CACHE_TTL) {
dashboard.value = await getDashboard()
dashboardCache = { data: dashboard.value, time: Date.now() }
}
dashboard.value = await getDashboard()
} catch {}
}
@@ -138,7 +135,7 @@ onShow(() => {
})
onPullDownRefresh(async () => {
await refreshDashboard(true)
await refreshDashboard()
uni.stopPullDownRefresh()
})
@@ -147,6 +144,8 @@ function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) }
function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) }
function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) }
function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
function goLogs() { uni.navigateTo({ url: '/pages/admin/logs' }) }
function goTrack() { uni.navigateTo({ url: '/pages/admin/track' }) }
</script>
<style lang="scss" scoped>

View File

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

View File

@@ -586,6 +586,7 @@ onReachBottom(() => {
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
}
.modal-header {

View File

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

View File

@@ -96,7 +96,7 @@ async function loadUsers(reset = false) {
}
loading.value = true
try {
const params: any = { page: page.value, pageSize: 20 }
const params: { page: number; pageSize: number; keyword?: string } = { page: page.value, pageSize: 20 }
if (keyword.value.trim()) params.keyword = keyword.value.trim()
const data = await getUsers(params)
if (reset) {

View File

@@ -79,8 +79,9 @@
<text class="state-text">加载失败下拉刷新重试</text>
</view>
<view class="empty" v-if="txList.length === 0 && !loading && !loadError">
<text class="empty-text">暂无账单记录</text>
<view class="state-box" v-if="txList.length === 0 && !loading && !loadError">
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">暂无账单记录</text>
</view>
</view>
</template>
@@ -98,6 +99,7 @@ import Skeleton from '@/components/Skeleton/Skeleton.vue'
import FilterPanel from './filter-panel.vue'
import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format'
import type { Transaction, TransactionParams } from '@/api/transaction'
import type { FilterParams } from '@/api/filter'
const userStore = useUserStore()
@@ -106,7 +108,7 @@ const loading = ref(false)
const filterType = ref('all')
const page = ref(1)
const pageSize = 20
const txList = ref<any[]>([])
const txList = ref<Transaction[]>([])
const total = ref(0)
const loadError = ref(false)
let loadSeq = 0 // 请求序号,防止并发覆盖
@@ -162,7 +164,7 @@ async function loadTx(reset = false, silent = false) {
if (!silent) txList.value = []
}
const seq = ++loadSeq
const params: any = { page: page.value, pageSize, group_id: groupStore.currentGroupId }
const params: TransactionParams & { group_id?: number | null; category_ids?: string } = { page: page.value, pageSize, group_id: groupStore.currentGroupId }
if (filterType.value !== 'all') params.type = filterType.value
// 合并高级筛选参数
const af = advancedFilters.value
@@ -240,7 +242,7 @@ async function onDelete(item: any) {
// 触底加载更多
onReachBottom(() => {
if (noMore.value || loading) return
if (noMore.value || loading.value) return
page.value++
loadTx(false)
})
@@ -369,13 +371,6 @@ onPullDownRefresh(() => {
.loading-text { font-size: $font-md; color: $text-muted; }
.empty {
text-align: center;
padding: 120rpx 0;
}
.empty-text { font-size: $font-lg; color: $text-muted; }
.state-box {
@include state-box;
justify-content: center;

View File

@@ -60,15 +60,24 @@
<!-- 自定义金额 -->
<view class="section-title">自定义金额</view>
<view class="custom-input">
<text class="input-prefix">¥</text>
<text class="input-value" :class="{ placeholder: !amountStr }">{{ amountStr }}</text>
<text class="input-cursor">|</text>
<text class="input-unit"></text>
</view>
<AmountEditor
ref="amountEditorRef"
v-model="amountStr"
:fixed="true"
size="large"
unit="元"
:auto-focus="false"
@focus="keyboardVisible = true"
@blur="keyboardVisible = false"
@confirm="onConfirm"
/>
</view>
<!-- Numpad -->
<Numpad v-model="amountStr" @confirm="onConfirm" />
<!-- 底部保存按钮键盘收起时显示 -->
<view class="save-bar" v-if="!keyboardVisible">
<view class="save-btn" @tap="onConfirm">
<text class="save-btn-text">保存预算</text>
</view>
</view>
</view>
</template>
@@ -81,21 +90,24 @@ import { useGroupStore } from '@/stores/group'
import { getOverview } from '@/api/stats'
import { waitForReady } from '@/utils/app-ready'
import { formatAmount, getCurrentMonth } from '@/utils/format'
import type { Budget } from '@/api/budget'
import { statusBarHeight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
import Numpad from '@/components/Numpad/Numpad.vue'
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
import Icon from '@/components/Icon/Icon.vue'
const budgetStore = useBudgetStore()
const groupStore = useGroupStore()
const currentMonth = getCurrentMonth()
const budget = ref<any>(null)
const budget = ref<Budget | null>(null)
const budgetAmount = ref(0)
const monthExpense = ref(0)
const loading = ref(true)
const initialLoaded = ref(false)
const amountStr = ref('')
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const keyboardVisible = ref(false)
const presets = [1000, 2000, 3000, 5000, 10000]
@@ -161,6 +173,7 @@ onPullDownRefresh(async () => {
function selectPreset(val: number) {
amountStr.value = String(val)
amountEditorRef.value?.focus()
}
async function onConfirm() {
@@ -194,7 +207,11 @@ function goBack() {
.page {
min-height: 100vh;
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 {
@@ -366,60 +383,45 @@ function goBack() {
color: $text;
}
.custom-input {
display: flex;
align-items: baseline;
gap: $space-xs;
margin-bottom: $space-lg;
padding: $space-md $space-lg;
// 底部保存按钮栏
.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;
border: 2rpx solid $border;
}
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
.input-prefix {
font-family: 'Fredoka', sans-serif;
font-size: $font-3xl;
font-weight: 600;
color: $primary;
}
.input-value {
font-family: 'Fredoka', sans-serif;
font-size: $font-4xl;
font-weight: 600;
color: $text;
flex: 1;
&.placeholder {
color: #D0C4C4;
&:active {
opacity: 0.8;
transform: scale(0.98);
}
}
.input-unit {
font-size: $font-lg;
color: $text-sec;
.save-btn-text {
font-size: $font-xl;
font-weight: 600;
color: $surface;
}
.input-cursor {
font-family: 'Fredoka', monospace;
font-size: $font-4xl;
font-weight: 300;
color: $primary;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.preview-skeleton {
animation: none;
}
.input-cursor {
animation: none;
opacity: 1;
}
}
</style>

View File

@@ -376,7 +376,7 @@ async function onMigrateConfirm() {
.fab {
position: fixed;
right: $space-xl;
bottom: 120rpx;
bottom: calc(120rpx + env(safe-area-inset-bottom));
width: 112rpx;
height: 112rpx;
background: linear-gradient(135deg, $primary, #E67355);

View File

@@ -645,6 +645,7 @@ function handleDissolve(group: Group) {
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: env(safe-area-inset-bottom);
}
.modal-header {

View File

@@ -146,6 +146,8 @@ import { useGroupStore } from '@/stores/group'
import { useNotificationStore } from '@/stores/notification'
import { waitForReady } from '@/utils/app-ready'
import { getOverview } from '@/api/stats'
import { syncRecurring } from '@/api/recurring'
import type { Transaction } from '@/api/transaction'
import { getNotificationImageUrl } from '@/api/notification'
import { formatAmount, formatAmountRaw } from '@/utils/format'
import { statusBarHeight, capsuleRight } from '@/utils/system'
@@ -165,7 +167,7 @@ const DEFAULT_BUDGET = 500000 // 5000元
const overview = computed(() => statsStore.overview)
const budget = computed(() => budgetStore.budget)
const recentTx = ref<any[]>([])
const recentTx = ref<Transaction[]>([])
const loading = ref(true)
const loadError = ref(false)
const todayExpense = ref(0)
@@ -202,18 +204,9 @@ function openLink(url: string) {
// #endif
}
// 数据新鲜度控制30秒内不重复请求
let lastLoadTime = 0
const CACHE_TTL = 30000
async function loadData(silent = false, force = false) {
async function loadData(silent = false) {
await waitForReady()
// 数据新鲜度判断30秒内不重复请求
if (!force && silent && Date.now() - lastLoadTime < CACHE_TTL) {
return
}
try {
if (!silent) loading.value = true
loadError.value = false
@@ -224,7 +217,6 @@ async function loadData(silent = false, force = false) {
txStore.fetchTransactions({ page: 1 })
])
recentTx.value = txStore.transactions.slice(0, 5)
lastLoadTime = Date.now()
// 第二级:次要数据(异步,不阻塞首屏)
Promise.all([
@@ -236,7 +228,8 @@ async function loadData(silent = false, force = false) {
Promise.allSettled([
userStore.fetchUserInfo(),
notifStore.fetchUnreadCount(),
notifStore.fetchUrgentNotifications()
notifStore.fetchUrgentNotifications(),
syncRecurring().catch(() => {})
]).catch(() => {})
} catch (e) {
console.error('Load error:', e)
@@ -268,11 +261,11 @@ onMounted(() => {
// Refresh data when returning from other pages (e.g., after adding a transaction)
onShow(() => {
if (initialLoaded.value && !loading.value) loadData(true, false)
if (initialLoaded.value && !loading.value) loadData(true)
})
onPullDownRefresh(() => {
loadData(false, true).finally(() => uni.stopPullDownRefresh())
loadData().finally(() => uni.stopPullDownRefresh())
})
function goAdd(type: string) {

View File

@@ -39,7 +39,7 @@
</view>
<view class="notif-content">
<view class="notif-title-row">
<text v-if="item.is_pinned" class="pin-badge">📌</text>
<text v-if="item.is_pinned" class="pin-badge">置顶</text>
<text class="notif-title">{{ item.title }}</text>
</view>
<text class="notif-body" v-if="item.content">{{ item.content }}</text>
@@ -355,7 +355,12 @@ onReachBottom(() => {
}
.pin-badge {
font-size: $font-md;
font-size: $font-xs;
color: $primary;
background: #FFE8E0;
padding: 2rpx 10rpx;
border-radius: $radius-xs;
font-weight: 500;
}
.notif-title {
@@ -435,6 +440,7 @@ onReachBottom(() => {
border-radius: $radius-2xl $radius-2xl 0 0;
display: flex;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
}
.detail-header {

View File

@@ -51,7 +51,11 @@
<text class="mi-label">分类管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="exportData">
<view class="menu-item" @tap="goRecurring">
<text class="mi-label">周期账单</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="openExportPanel">
<text class="mi-label">数据导出</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
@@ -94,6 +98,60 @@
</view>
</view>
<!-- 导出弹窗 -->
<view class="modal-mask" v-if="showExportPanel" @tap="showExportPanel = false">
<view class="export-modal" @tap.stop>
<text class="modal-title">数据导出</text>
<!-- 日期范围 -->
<view class="export-section">
<text class="export-label">日期范围</text>
<view class="export-date-row">
<picker mode="date" :value="exportStartDate" :end="exportEndDate" @change="e => { exportStartDate = e.detail.value; updateExportCount() }">
<view class="export-date-picker">
<Icon name="calendar" :size="24" color="#8B7E7E" />
<text class="export-date-text">{{ exportStartDate }}</text>
</view>
</picker>
<text class="export-date-sep"></text>
<picker mode="date" :value="exportEndDate" :start="exportStartDate" :end="getLocalDateStr()" @change="e => { exportEndDate = e.detail.value; updateExportCount() }">
<view class="export-date-picker">
<Icon name="calendar" :size="24" color="#8B7E7E" />
<text class="export-date-text">{{ exportEndDate }}</text>
</view>
</picker>
</view>
</view>
<!-- 类型筛选 -->
<view class="export-section">
<text class="export-label">交易类型</text>
<view class="export-tabs">
<view class="export-tab" :class="{ active: exportType === 'all' }" @tap="exportType = 'all'; updateExportCount()">全部</view>
<view class="export-tab" :class="{ active: exportType === 'expense' }" @tap="exportType = 'expense'; updateExportCount()">仅支出</view>
<view class="export-tab" :class="{ active: exportType === 'income' }" @tap="exportType = 'income'; updateExportCount()">仅收入</view>
</view>
</view>
<!-- 导出格式 -->
<view class="export-section">
<text class="export-label">导出格式</text>
<view class="export-tabs">
<view class="export-tab" :class="{ active: exportFormat === 'csv' }" @tap="exportFormat = 'csv'">CSV</view>
<view class="export-tab" :class="{ active: exportFormat === 'json' }" @tap="exportFormat = 'json'">JSON</view>
</view>
</view>
<!-- 预览 & 导出 -->
<view class="export-footer">
<text class="export-preview"> <text class="export-count">{{ exportCount }}</text> 条记录</text>
<view class="export-btn" :class="{ disabled: exportCount === 0 || exportLoading }" @tap="doExport">
<text class="export-btn-text">{{ exportLoading ? '导出中...' : '导出 ' + exportFormat.toUpperCase() }}</text>
</view>
</view>
</view>
</view>
<!-- 身份选择弹窗 -->
<view class="modal-mask" v-if="showIdentity" @tap="showIdentity = false">
<view class="identity-modal" @tap.stop>
@@ -144,6 +202,7 @@ import { useGroupStore } from '@/stores/group'
import { useConfigStore } from '@/stores/config'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions } from '@/api/transaction'
import type { Transaction } from '@/api/transaction'
import { formatAmount } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import Icon from '@/components/Icon/Icon.vue'
@@ -162,6 +221,35 @@ const showAbout = ref(false)
const showIdentity = ref(false)
const initialLoaded = ref(false)
// 导出面板状态
const showExportPanel = ref(false)
const exportStartDate = ref(getLocalDateStr().replace(/-/g, '-').replace(/(\d+)$/, '01')) // 本月1日
const exportEndDate = ref(getLocalDateStr())
const exportType = ref<'all' | 'expense' | 'income'>('all')
const exportFormat = ref<'csv' | 'json'>('csv')
const exportCount = ref(0)
const exportLoading = ref(false)
function openExportPanel() {
showExportPanel.value = true
updateExportCount()
}
async function updateExportCount() {
try {
const data = await getTransactions({
page: 1, pageSize: 1,
startDate: exportStartDate.value,
endDate: exportEndDate.value,
...(exportType.value !== 'all' ? { type: exportType.value } : {}),
group_id: groupStore.currentGroupId
})
exportCount.value = data.total
} catch {
exportCount.value = 0
}
}
onMounted(async () => {
await waitForReady()
loading.value = true
@@ -217,6 +305,10 @@ function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' })
}
function goRecurring() {
uni.navigateTo({ url: '/pages/recurring/index' })
}
function goPrivacy() {
uni.navigateTo({ url: '/pages/privacy/index' })
}
@@ -322,19 +414,28 @@ function getLocalDateStr(): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
async function exportData() {
async function doExport() {
if (exportCount.value === 0 || exportLoading.value) return
exportLoading.value = true
uni.showLoading({ title: '导出中 0%' })
try {
// 分页获取全部数据(服务端 pageSize 上限 100
const allList: any[] = []
// 分页获取筛选后的数据
const allList: Transaction[] = []
let page = 1
let total = 0
const baseParams: Record<string, any> = {
pageSize: 100,
startDate: exportStartDate.value,
endDate: exportEndDate.value,
group_id: groupStore.currentGroupId
}
if (exportType.value !== 'all') baseParams.type = exportType.value
do {
const data = await getTransactions({ page, pageSize: 100, group_id: groupStore.currentGroupId })
const data = await getTransactions({ page, ...baseParams } as any)
allList.push(...data.list)
total = data.total
page++
// 更新进度
const progress = Math.min(100, Math.round((allList.length / total) * 100))
uni.showLoading({ title: `导出中 ${progress}%` })
} while (allList.length < total)
@@ -345,50 +446,71 @@ async function exportData() {
return
}
function csvEscape(val: string): string {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"'
if (exportFormat.value === 'json') {
const json = JSON.stringify(allList.map(t => ({
date: t.date,
type: t.type === 'expense' ? '支出' : '收入',
category: t.category_name || '未分类',
amount: (t.amount / 100).toFixed(2),
note: t.note || ''
})), null, 2)
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.json`
saveFile(json, fileName, 'application/json')
} else {
function csvEscape(val: string): string {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"'
}
return val
}
return val
const header = '日期,类型,分类,金额(元),备注\n'
const rows = allList.map(t => {
const amount = (t.amount / 100).toFixed(2)
const type = t.type === 'expense' ? '支出' : '收入'
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
}).join('\n')
const csv = '' + header + rows
const fileName = `小菜记账_${exportStartDate.value}_${exportEndDate.value}.csv`
saveFile(csv, fileName, 'text/csv')
}
const header = '日期,类型,分类,金额(元),备注\n'
const rows = allList.map(t => {
const amount = (t.amount / 100).toFixed(2)
const type = t.type === 'expense' ? '支出' : '收入'
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
}).join('\n')
const csv = '' + header + rows
// #ifdef H5
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `小菜记账_${getLocalDateStr()}.csv`
a.click()
URL.revokeObjectURL(url)
uni.hideLoading()
uni.showToast({ title: '导出成功', icon: 'success' })
// #endif
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${getLocalDateStr()}.csv`
fs.writeFileSync(filePath, csv, 'utf-8')
uni.hideLoading()
uni.openDocument({
filePath,
fileType: 'csv',
success: () => {},
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
})
// #endif
} catch {
uni.hideLoading()
uni.showToast({ title: '导出失败', icon: 'none' })
} finally {
exportLoading.value = false
showExportPanel.value = false
}
}
function saveFile(content: string, fileName: string, mimeType: string) {
// #ifdef H5
const blob = new Blob([content], { type: `${mimeType};charset=utf-8;` })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
uni.hideLoading()
uni.showToast({ title: '导出成功', icon: 'success' })
// #endif
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/${fileName}`
fs.writeFileSync(filePath, content, 'utf-8')
uni.hideLoading()
const ext = mimeType === 'application/json' ? 'json' : 'csv'
uni.openDocument({
filePath,
fileType: ext,
success: () => {},
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
})
// #endif
}
</script>
<style lang="scss" scoped>
@@ -725,4 +847,122 @@ async function exportData() {
font-size: $font-base;
color: $text-sec;
}
/* 导出弹窗 */
.export-modal {
width: 100%;
max-height: 85vh;
background: $surface;
border-radius: $radius-2xl $radius-2xl 0 0;
padding: $space-xl;
padding-bottom: calc($space-xl + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
}
.export-section {
margin-bottom: $space-lg;
}
.export-label {
font-size: $font-base;
font-weight: 600;
color: $text;
display: block;
margin-bottom: $space-sm;
}
.export-date-row {
display: flex;
align-items: center;
gap: $space-sm;
}
.export-date-picker {
flex: 1;
display: flex;
align-items: center;
gap: $space-xs;
height: 72rpx;
padding: 0 $space-md;
background: $bg;
border-radius: $radius-lg;
border: 2rpx solid $border;
}
.export-date-text {
font-size: $font-md;
color: $text;
}
.export-date-sep {
font-size: $font-md;
color: $text-muted;
flex-shrink: 0;
}
.export-tabs {
display: flex;
gap: $space-xs;
}
.export-tab {
padding: $space-sm $space-md;
border-radius: $radius-lg;
font-size: $font-md;
color: $text-sec;
background: $bg;
border: 2rpx solid $border;
transition: all $transition-normal;
&.active {
background: $primary;
color: $surface;
border-color: $primary;
}
}
.export-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: $space-md;
border-top: 2rpx solid $border;
}
.export-preview {
font-size: $font-lg;
color: $text-sec;
}
.export-count {
font-family: 'Fredoka', sans-serif;
font-size: $font-2xl;
font-weight: 600;
color: $primary;
}
.export-btn {
height: 80rpx;
padding: 0 $space-xl;
background: linear-gradient(135deg, $primary, #E67355);
border-radius: $radius-lg;
display: flex;
align-items: center;
justify-content: center;
gap: $space-xs;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
&:active { opacity: 0.8; }
}
.export-btn-text {
font-size: $font-lg;
font-weight: 600;
color: $surface;
}
</style>

View File

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

View File

@@ -548,6 +548,10 @@ function getBarWidth(amount: number) {
border-radius: 6rpx;
background: linear-gradient(90deg, $primary, #FFB89A);
transition: width 0.6s ease-out;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
.trend-amount {

View File

@@ -55,7 +55,7 @@ export const useGroupStore = defineStore('group', () => {
if (currentGroupId.value !== null) {
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
if (!stillValid) {
switchToPersonal()
await switchToPersonal()
}
}
} catch {
@@ -83,7 +83,7 @@ export const useGroupStore = defineStore('group', () => {
async function leaveGroup(groupId: number) {
await api.leaveGroup(groupId)
if (currentGroupId.value === groupId) {
switchToPersonal()
await switchToPersonal()
}
await fetchGroups()
}
@@ -92,7 +92,7 @@ export const useGroupStore = defineStore('group', () => {
async function deleteGroup(groupId: number) {
await api.deleteGroup(groupId)
if (currentGroupId.value === groupId) {
switchToPersonal()
await switchToPersonal()
}
await fetchGroups()
}

View File

@@ -11,7 +11,7 @@ export const useUserStore = defineStore('user', () => {
})
const slogan = computed(() => {
return userInfo.value?.slogan || '记账小能手'
return userInfo.value?.slogan
})
const avatarUrl = computed(() => {

View File

@@ -36,7 +36,11 @@ export function formatDate(date: string | Date): string {
if (diff === 1) return '昨天'
if (diff === 2) return '前天'
return `${d.getMonth() + 1}${d.getDate()}`
// 跨年显示年份
const isCurrentYear = d.getFullYear() === now.getFullYear()
return isCurrentYear
? `${d.getMonth() + 1}${d.getDate()}`
: `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`
}
export function formatMonth(date: string): string {

View File

@@ -1,5 +1,6 @@
import { API_BASE } from '@/config'
import type { LoginResult } from '@/api/auth'
import { trackApiError } from './tracker'
interface RequestOptions {
url: string
@@ -46,6 +47,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
url: API_BASE + options.url,
method: options.method || 'GET',
data: options.data,
timeout: 15000, // 15秒超时
header: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
@@ -88,6 +90,8 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
if (data.code === 0) {
resolve(data.data)
} else {
// 埋点API 业务错误
trackApiError(options.url, statusCode, data.message || '请求失败')
// "用户不存在" 不弹 toast首次登录竞态
if (data.code !== 40400 || data.message !== '用户不存在') {
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
@@ -96,6 +100,8 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
}
},
fail: (err: any) => {
// 埋点:网络错误
trackApiError(options.url, 0, '网络错误')
uni.showToast({ title: '网络错误', icon: 'none' })
reject(err)
}

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

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

View File

@@ -0,0 +1,320 @@
# S1 全面扫描 — 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 对「小菜记账」全项目进行三维度(缺陷/UI/功能)全面扫描,产出结构化问题清单
**Architecture:** 5 个并行扫描组 + 1 个汇总任务。每组用 Explore agent 深度检查文件,从缺陷/UI/功能三个维度输出 P0-P3 优先级发现。
**Tech Stack:** TypeScript, Vue 3, Uni-app, Express, MySQL
---
## 文件结构
```
client/src/ server/src/
├── pages/ ├── index.ts
│ ├── index/index.vue ├── middleware/
│ ├── add/index.vue │ ├── auth.ts
│ ├── bills/ │ ├── logger.ts
│ │ ├── index.vue │ └── requireAdmin.ts
│ │ └── filter-panel.vue ├── routes/
│ ├── stats/index.vue │ ├── auth.ts
│ ├── profile/index.vue │ ├── transaction.ts
│ ├── profile-edit/index.vue │ ├── category.ts
│ ├── budget/index.vue │ ├── budget.ts
│ ├── category-manage/index.vue │ ├── stats.ts
│ ├── group-manage/index.vue │ ├── user.ts
│ ├── notifications/index.vue │ ├── group.ts
│ ├── feedback/index.vue │ ├── notification.ts
│ ├── privacy/index.vue │ ├── filter.ts
│ └── admin/ │ ├── feedback.ts
│ ├── index.vue │ ├── config.ts
│ ├── users.vue │ ├── admin.ts
│ ├── notifications.vue │ ├── track.ts
│ ├── feedback.vue │ ├── logs.ts
│ ├── config.vue │ └── backup.ts
│ ├── logs.vue ├── utils/
│ └── track.vue │ ├── backup.ts
├── components/ │ └── date.ts
│ ├── Icon/Icon.vue └── db/
│ ├── Numpad/Numpad.vue ├── connection.ts
│ ├── TransactionItem/...vue └── init.ts
│ ├── Skeleton/Skeleton.vue
│ ├── SaveSuccess/...vue
│ ├── BudgetBar/BudgetBar.vue
│ ├── CategoryIcon/...vue
│ └── ChartWrapper/...vue
├── stores/
│ ├── transaction.ts
│ ├── category.ts
│ ├── budget.ts
│ ├── stats.ts
│ ├── user.ts
│ ├── group.ts
│ ├── filter.ts
│ ├── notification.ts
│ └── config.ts
├── api/
│ ├── auth.ts, transaction.ts, category.ts
│ ├── budget.ts, stats.ts, user.ts
│ ├── group.ts, notification.ts, filter.ts
│ ├── feedback.ts, config.ts, admin.ts, logs.ts
│ └── index.ts
├── utils/
│ ├── request.ts, format.ts, system.ts
│ ├── app-ready.ts, tracker.ts
└── config.ts
```
---
## 扫描检查清单(每个文件按此标准检查)
每个文件从以下三个维度检查:
### 🔧 缺陷维度
- [ ] API 参数类型:`request()` 调用是否使用 `{ url, method?, data? }` 格式
- [ ] null/undefined 处理:是否可能传递 undefined 到查询参数
- [ ] 边界条件空数组、空字符串、0 值的处理
- [ ] 竞态条件:快速切换页面时请求是否可能覆盖
- [ ] 小程序适配:胶囊按钮避让 (capsuleRight)、scroll-view 高度
- [ ] 数据加载onMounted 是否先 await waitForReady()
- [ ] catch 块:是否有用户提示而非静默失败
- [ ] 后端响应格式:是否返回 `{ code: 0, data }``{ code: xxx, message }`
### 🎨 UI 维度
- [ ] 加载态:是否有 loading/skeleton避免白屏
- [ ] 空状态:无数据时是否有友好的空状态提示
- [ ] 错误态:是否有错误提示机制
- [ ] 触摸反馈:可点击元素是否有 `:active` 样式
- [ ] 设计一致性:颜色/圆角/阴影使用 SCSS 变量,不硬编码
- [ ] 触摸目标:交互元素 ≥ 44×44px
- [ ] emoji禁止使用 emoji必须用 Icon 组件
- [ ] 动画:是否尊重 prefers-reduced-motion
- [ ] 文字对比度:≥ 4.5:1 (WCAG AA)
### 🚀 功能维度
- [ ] 分页:是否正确分页,是否有「没有更多了」
- [ ] 筛选:筛选条件变更时是否重置页码
- [ ] 输入校验:前端是否有校验,后端是否有校验
- [ ] 错误码规范0/40001/40300/40400/50000
- [ ] 数据流API → store → 组件是否清晰
### 优先级定义
| 级别 | 含义 | 示例 |
|------|------|------|
| P0 | 数据/安全/崩溃 | 金额错误、SQL 注入、白屏 |
| P1 | 功能不可用 | 按钮无响应、数据不刷新、接口报错 |
| P2 | 体验不佳 | 空状态缺失、加载闪烁 |
| P3 | 建议改进 | 代码整洁、命名、注释 |
---
### Task 1: 扫描组 1 — 核心页面
**Files (13):**
- `client/src/pages/index/index.vue`
- `client/src/pages/add/index.vue`
- `client/src/pages/bills/index.vue`
- `client/src/pages/bills/filter-panel.vue`
- `client/src/pages/stats/index.vue`
- `client/src/stores/transaction.ts`
- `client/src/stores/category.ts`
- `client/src/stores/budget.ts`
- `client/src/stores/stats.ts`
- `client/src/api/transaction.ts`
- `client/src/api/category.ts`
- `client/src/api/budget.ts`
- `client/src/api/stats.ts`
- [ ] **Step 1: 并行读取所有文件**
使用 Explore agent读取上述全部 13 个文件,对每个文件按「扫描检查清单」逐项检查。
- [ ] **Step 2: 输出结构化发现清单**
输出格式:
```markdown
## 组 1: 核心页面 — 扫描结果
### 🔧 缺陷
| 优先级 | 文件:行号 | 问题 | 影响 |
|--------|----------|------|------|
| P1 | pages/bills/index.vue:42 | onMounted 缺少 waitForReady | 登录未完成就发请求,可能 401 |
### 🎨 UI 问题
| 优先级 | 文件:行号 | 问题 | 影响 |
|--------|----------|------|------|
| P2 | pages/index/index.vue:xxx | 无空状态提示 | 新用户看到空白页 |
### 🚀 功能缺口
| 优先级 | 文件:行号 | 问题 | 建议 |
|--------|----------|------|------|
| P2 | stores/transaction.ts | 无请求去重 | 快速点击可能重复提交 |
```
---
### Task 2: 扫描组 2 — 用户 & 设置
**Files (18):**
- `client/src/pages/profile/index.vue`
- `client/src/pages/profile-edit/index.vue`
- `client/src/pages/budget/index.vue`
- `client/src/pages/category-manage/index.vue`
- `client/src/pages/group-manage/index.vue`
- `client/src/pages/notifications/index.vue`
- `client/src/pages/feedback/index.vue`
- `client/src/pages/privacy/index.vue`
- `client/src/stores/user.ts`
- `client/src/stores/group.ts`
- `client/src/stores/filter.ts`
- `client/src/stores/notification.ts`
- `client/src/stores/config.ts`
- `client/src/api/user.ts`
- `client/src/api/group.ts`
- `client/src/api/notification.ts`
- `client/src/api/feedback.ts`
- `client/src/api/filter.ts`
- [ ] **Step 1: 并行读取所有文件**
使用 Explore agent读取上述全部 18 个文件,对每个文件按「扫描检查清单」逐项检查。
- [ ] **Step 2: 输出结构化发现清单**
格式同 Task 1。
---
### Task 3: 扫描组 3 — 管理后台
**Files (10):**
- `client/src/pages/admin/index.vue`
- `client/src/pages/admin/users.vue`
- `client/src/pages/admin/notifications.vue`
- `client/src/pages/admin/feedback.vue`
- `client/src/pages/admin/config.vue`
- `client/src/pages/admin/logs.vue`
- `client/src/pages/admin/track.vue`
- `client/src/api/admin.ts`
- `client/src/api/config.ts`
- `client/src/api/logs.ts`
- [ ] **Step 1: 并行读取所有文件**
使用 Explore agent读取上述全部 10 个文件,对每个文件按「扫描检查清单」逐项检查。
- [ ] **Step 2: 输出结构化发现清单**
格式同 Task 1。
---
### Task 4: 扫描组 4 — 后端路由 & 基础设施
**Files (23):**
- `server/src/index.ts`
- `server/src/middleware/auth.ts`
- `server/src/middleware/logger.ts`
- `server/src/middleware/requireAdmin.ts`
- `server/src/routes/auth.ts`
- `server/src/routes/transaction.ts`
- `server/src/routes/category.ts`
- `server/src/routes/budget.ts`
- `server/src/routes/stats.ts`
- `server/src/routes/user.ts`
- `server/src/routes/group.ts`
- `server/src/routes/notification.ts`
- `server/src/routes/filter.ts`
- `server/src/routes/feedback.ts`
- `server/src/routes/config.ts`
- `server/src/routes/admin.ts`
- `server/src/routes/track.ts`
- `server/src/routes/logs.ts`
- `server/src/routes/backup.ts`
- `server/src/utils/backup.ts`
- `server/src/utils/date.ts`
- `server/src/db/connection.ts`
- `server/src/db/init.ts`
- [ ] **Step 1: 并行读取所有文件**
使用 Explore agent读取上述全部 23 个文件,对每个文件按以下关注点检查:
**后端专项检查:**
- SQL 注入风险(参数化查询)
- 输入校验(类型、范围)
- 响应格式统一(`{ code, data/message }`
- 错误码规范
- LIMIT/OFFSET 参数类型(整数)
- 认证/授权逻辑完整性
- 日志记录完整性
- [ ] **Step 2: 输出结构化发现清单**
格式同 Task 1。
---
### Task 5: 扫描组 5 — 公共组件 & 基础设施
**Files (12):**
- `client/src/components/Icon/Icon.vue`
- `client/src/components/Numpad/Numpad.vue`
- `client/src/components/TransactionItem/TransactionItem.vue`
- `client/src/components/Skeleton/Skeleton.vue`
- `client/src/components/SaveSuccess/SaveSuccess.vue`
- `client/src/components/BudgetBar/BudgetBar.vue`
- `client/src/components/CategoryIcon/CategoryIcon.vue`
- `client/src/components/ChartWrapper/ChartWrapper.vue`
- `client/src/utils/request.ts`
- `client/src/utils/format.ts`
- `client/src/utils/tracker.ts`
- `client/src/config.ts`
- [ ] **Step 1: 并行读取所有文件**
使用 Explore agent读取上述全部 12 个文件,对每个文件按「扫描检查清单」逐项检查。
- [ ] **Step 2: 输出结构化发现清单**
格式同 Task 1。
---
### Task 6: 汇总 & 排期
- [ ] **Step 1: 合并 5 组发现清单**
将所有扫描结果合并为一文档,按优先级分组:
```markdown
# S1 全面扫描 — 汇总报告
## P0 — 立即修复N 项)
| 组 | 文件 | 问题 |
|----|------|------|
## P1 — 高优先级N 项)
...
## P2 — 中优先级N 项)
...
## P3 — 建议改进N 项)
...
```
- [ ] **Step 2: 提交汇总报告**
```bash
git add docs/superpowers/
git commit -m "docs: S1 全面扫描汇总报告"
```

View File

@@ -0,0 +1,107 @@
# S1 全面扫描 — 汇总报告
日期: 2026-06-08
## 统计总览
| 优先级 | 数量 | 说明 |
|--------|------|------|
| P0 | 0 | 无数据丢失/安全漏洞/崩溃风险 |
| P1 | 1 | 功能问题 |
| P2 | 4 | 体验/规范问题 |
| P3 | 7 | 建议改进 |
| **合计** | **12** | |
---
## P1 — 高优先级1 项)
### 1. notifications 页面使用 emoji 违反设计规范
| 文件 | 行号 | 问题 | 影响 |
|------|------|------|------|
| [notifications/index.vue](client/src/pages/notifications/index.vue) | 43 | `<text class="pin-badge">📌</text>` 使用 emoji 作为图钉图标 | 设计规范明确禁止 emoji必须使用 Icon 组件 |
**修复方向**:将 `📌` 替换为 `<Icon name="pin" ... />`
---
## P2 — 中优先级4 项)
### 2. stats 趋势条动画缺少 prefers-reduced-motion
| 文件 | 行号 | 问题 | 影响 |
|------|------|------|------|
| [stats/index.vue](client/src/pages/stats/index.vue) | 550 | `.trend-bar { transition: width 0.6s ease-out; }``prefers-reduced-motion` 处理 | 对动画敏感用户不友好 |
**修复方向**:添加 `@media (prefers-reduced-motion: reduce) { .trend-bar { transition: none; } }`
### 3. admin/logs 页面日期选择触发双重请求
| 文件 | 行号 | 问题 | 影响 |
|------|------|------|------|
| [admin/logs.vue](client/src/pages/admin/logs.vue) | 181-185, 206-209 | `selectDate()` 直接调用 `loadLogs()`,同时 `watch([filterLevel, selectedDate])` 也触发 `loadLogs()`,导致日期变更时请求发两次 | 浪费带宽,可能导致竞态 |
**修复方向**`selectDate()` 只设置 `selectedDate.value`,删除手动 `loadLogs()` 调用,由 watcher 统一触发。
### 4. bills 页面列表参数使用 any 类型
| 文件 | 行号 | 问题 | 影响 |
|------|------|------|------|
| [bills/index.vue](client/src/pages/bills/index.vue) | 109, 165 | `txList: any[]``params: any` 丢失类型安全 | 重构时易引入 bug |
**修复方向**:使用 `Transaction[]``TransactionParams` 类型。
### 5. bills 空状态缺少图标
| 文件 | 行号 | 问题 | 影响 |
|------|------|------|------|
| [bills/index.vue](client/src/pages/bills/index.vue) | 82-84 | 空状态只显示文字「暂无账单记录」,无图标 | 与其他页面空状态不一致(其他页面都有 Icon |
**修复方向**:添加 `<Icon name="edit" :size="48" color="#BFB3B3" />`
---
## P3 — 建议改进7 项)
### 6. 多处使用 `any` 类型
| 文件 | 行号 | 问题 |
|------|------|------|
| [index/index.vue](client/src/pages/index/index.vue) | 168 | `recentTx` 类型 `any[]`,应为 `Transaction[]` |
| [add/index.vue](client/src/pages/add/index.vue) | 139 | `getCurrentPages()` 结果 cast `as any` |
| [budget/index.vue](client/src/pages/budget/index.vue) | 93 | `budget` 类型 `any`,应为 `Budget \| null` |
| [profile/index.vue](client/src/pages/profile/index.vue) | 329 | `allList` 类型 `any[]`,应为 `Transaction[]` |
| [admin/users.vue](client/src/pages/admin/users.vue) | 99 | `params` 类型 `any`,应为具名类型 |
| [admin/feedback.vue](client/src/pages/admin/feedback.vue) | 105 | `list` 类型 `any[]`,应使用 Feedback 类型 |
### 7. stats 页面 loadData / loadTabData 代码重复
| 文件 | 行号 | 问题 |
|------|------|------|
| [stats/index.vue](client/src/pages/stats/index.vue) | 245-281 | `loadData``loadTabData` 有大量重复代码loadSeq、错误处理可抽取公共函数 |
---
## 总体评价
代码质量整体良好,主要体现在:
-**认证机制完善**HMAC-SHA256 token + timingSafeEqual30天过期
-**竞态保护**bills、stats、notifications 页面均使用 `loadSeq` 防止并发覆盖
-**401 队列重试**`request.ts` 自动重登录 + 队列重试,设计精良
-**统一设计系统**SCSS 变量、mixin 复用度高Claymorphism 风格统一
-**响应格式统一**:后端 API 返回 `{ code, data/message }` 标准格式
-**输入校验**:前后端均有校验(金额范围、必填字段、类型白名单)
-**参数化查询**:后端 SQL 使用 `?` 占位符,无 SQL 注入风险
-**空状态/加载态/错误态**:大部分页面三态齐全
-**小程序适配**:胶囊按钮避让 `capsuleRight` 使用正确
-**数据加载模式**`waitForReady()` + `initialLoaded` 守卫模式统一
-**动画可访问性**:大部分页面有 `prefers-reduced-motion` 处理
主要改进方向集中在:
1. 消除 emoji1 处)
2. 补全 `prefers-reduced-motion`1 处)
3. 修复重复请求1 处)
4. 统一空状态样式1 处)
5. 减少 `any` 类型使用6 处)

View File

@@ -0,0 +1,110 @@
# 小菜记账 — 质量迭代设计
日期: 2026-06-08
## 概述
对「小菜记账」进行全面质量迭代,分四个阶段推进:全面扫描 → 缺陷修复 → UI 交互优化 → 系统功能迭代。
## 总体方案:分阶段逐层推进
```
S1 全面扫描 → S2 缺陷修复 → S3 UI 优化 → S4 功能迭代
```
每阶段有独立验收标准,后续阶段基于前一阶段的稳定基线。
---
## S1: 全面扫描
### 扫描维度
对每个文件从三个维度同时检查:
#### 🔧 缺陷维度
- **类型安全**: API 参数类型错误、null/undefined 处理不当、`any` 滥用
- **边界条件**: 空数据、空字符串、0 值、数组越界
- **竞态条件**: 快速切换页面时未取消的请求
- **小程序适配**: 胶囊按钮避让缺失、scroll-view 高度计算错误
- **内存泄漏**: 未清理的 timer、watcher、事件监听
- **响应格式**: 后端路由是否统一返回 `{ code, data/message }`
- **错误码规范**: 是否符合 0=成功, 40001=参数错误, 40300=权限, 40400=不存在, 50000=服务器错误
#### 🎨 UI 维度
- **加载态**: 是否有 loading/skeleton避免白屏
- **空状态**: 是否有友好的空状态提示(图标+文案)
- **错误态**: 网络错误/超时是否有用户提示
- **触摸反馈**: 按钮/列表项是否有点击态(`:active` 样式)
- **动画过渡**: 是否尊重 `prefers-reduced-motion`
- **设计一致性**: 颜色、圆角、阴影是否使用 SCSS 变量,而非硬编码
- **触摸目标**: 是否 ≥ 44×44px小程序最小触摸区域
- **emoji 检查**: 是否混用 emoji 代替 Icon 组件(设计规范禁止 emoji
#### 🚀 功能维度
- **数据完整性**: 分页是否正确、筛选是否生效
- **校验完整性**: 前端+后端是否都有输入校验
- **错误处理**: catch 块是否有用户提示(而非静默失败)
- **数据流**: API → store → 组件数据流是否清晰,避免 prop drilling
### 扫描分组
| 扫描组 | 覆盖范围 | 文件数(约) |
|--------|---------|------------|
| 组 1: 核心页面 | 首页、记账、账单、统计 + 对应 store + API | ~10 |
| 组 2: 用户 & 设置 | 个人、编辑资料、预算、分类管理、群组、通知、反馈、隐私 | ~12 |
| 组 3: 管理后台 | 仪表盘、用户管理、公告、反馈、配置、日志、埋点 | ~10 |
| 组 4: 后端路由 | 15 个路由 + 中间件 + 工具函数 | ~20 |
| 组 5: 公共组件 & 基础设施 | Icon、Numpad、TransactionItem、Skeleton、request、tracker 等 | ~10 |
### 产出物
每个扫描组输出结构化发现清单,每条发现包含:
- **优先级**: P0(立即)/P1(高)/P2(中)/P3(低)
- **文件**: 精确到行号
- **问题描述**: 清晰说明缺陷/问题
- **影响**: 用户可见影响
所有发现汇总到统一清单,供审核和排期。
### 优先级定义
| 级别 | 含义 | 示例 |
|------|------|------|
| P0 | 数据丢失/安全/崩溃 | 金额计算错误、SQL 注入、白屏 |
| P1 | 功能不可用/严重体验问题 | 按钮无响应、数据不刷新、接口报错 |
| P2 | 体验不佳/边界情况 | 空状态缺失、加载闪烁、动画卡顿 |
| P3 | 建议改进 | 代码整洁、变量命名、注释补充 |
---
## S2: 缺陷修复(扫描后细化)
- 按 P0 → P1 → P2 顺序修复
- 每个修复前先写测试用例复现
- 修复后验证已有测试通过
---
## S3: UI 交互优化(扫描后细化)
- 统一加载/空/错误状态组件
- 统一触摸反馈模式
- 动画一致性检查
- 无障碍改进
---
## S4: 系统功能迭代(扫描后细化)
- 基于扫描发现的功能缺口
- 新功能需写 spec → plan → implement
---
## 不涉及的范围
- 不修改 `dist/` 目录
- 不修改 lock 文件
- 不做无关重构
- 不做数据库结构大改(如需迁移需单独评审)

View File

@@ -214,7 +214,7 @@ async function runMigrations(conn: mysql.Connection) {
content TEXT NOT NULL COMMENT '反馈内容',
contact VARCHAR(100) DEFAULT '' COMMENT '联系方式',
status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态',
admin_reply TEXT DEFAULT '' 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),
@@ -224,6 +224,59 @@ async function runMigrations(conn: mysql.Connection) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// 埋点事件表
const hasTrackEvents = await tableExists(conn, 'track_events')
if (!hasTrackEvents) {
console.log('[DB] Migrating: creating track_events table')
await conn.query(`
CREATE TABLE IF NOT EXISTS track_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event VARCHAR(50) NOT NULL COMMENT '事件类型',
page VARCHAR(100) DEFAULT '' COMMENT '页面路径',
data JSON DEFAULT NULL COMMENT '事件数据',
user_id INT DEFAULT NULL COMMENT '用户ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_event (event),
INDEX idx_created (created_at),
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// 周期账单模板表
const hasRecurring = await tableExists(conn, 'recurring_templates')
if (!hasRecurring) {
console.log('[DB] Migrating: creating recurring_templates table')
await conn.query(`
CREATE TABLE IF NOT EXISTS recurring_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount INT NOT NULL COMMENT '单位:分',
type ENUM('expense', 'income') NOT NULL,
category_id INT NOT NULL,
note VARCHAR(200) DEFAULT '',
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
next_date DATE NOT NULL COMMENT '下次生成日期',
end_date DATE DEFAULT NULL COMMENT '截止日期NULL=永久',
is_active TINYINT(1) DEFAULT 1,
group_id INT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_next_date (next_date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// transactions 表添加 recurring_id
const hasRecurringId = await columnExists(conn, 'transactions', 'recurring_id')
if (!hasRecurringId) {
console.log('[DB] Migrating: adding recurring_id to transactions')
await conn.query("ALTER TABLE transactions ADD COLUMN recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID' AFTER group_id")
}
}
export async function initDatabase() {

View File

@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS transactions (
note VARCHAR(200),
date DATE NOT NULL,
group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)',
recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_date (user_id, date),
@@ -123,7 +124,7 @@ CREATE TABLE IF NOT EXISTS feedbacks (
content TEXT NOT NULL COMMENT '反馈内容',
contact VARCHAR(100) DEFAULT '' COMMENT '联系方式',
status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态',
admin_reply TEXT DEFAULT '' 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),
@@ -137,3 +138,35 @@ CREATE TABLE IF NOT EXISTS sys_config (
config_value TEXT NOT NULL COMMENT '配置值',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS track_events (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event VARCHAR(50) NOT NULL COMMENT '事件类型',
page VARCHAR(100) DEFAULT '' COMMENT '页面路径',
data JSON DEFAULT NULL COMMENT '事件数据',
user_id INT DEFAULT NULL COMMENT '用户ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_event (event),
INDEX idx_created (created_at),
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS recurring_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount INT NOT NULL COMMENT '单位:分',
type ENUM('expense', 'income') NOT NULL,
category_id INT NOT NULL,
note VARCHAR(200) DEFAULT '',
frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly',
next_date DATE NOT NULL COMMENT '下次生成日期',
end_date DATE DEFAULT NULL COMMENT '截止日期NULL=永久',
is_active TINYINT(1) DEFAULT 1,
group_id INT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_next_date (next_date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -5,6 +5,7 @@ import rateLimit from 'express-rate-limit'
import path from 'path'
import fs from 'fs'
import { authMiddleware } from './middleware/auth'
import { requestLogger } from './middleware/logger'
import { initDatabase } from './db/init'
import pool from './db/connection'
import authRoutes from './routes/auth'
@@ -20,6 +21,9 @@ import filterRoutes from './routes/filter'
import adminRoutes from './routes/admin'
import feedbackRoutes from './routes/feedback'
import configRoutes from './routes/config'
import trackRoutes from './routes/track'
import logsRoutes from './routes/logs'
import recurringRoutes from './routes/recurring'
import { backupDatabase } from './utils/backup'
// Warn if token secret is using default fallback
@@ -48,6 +52,9 @@ app.use(cors({
}))
app.use(express.json({ limit: '10kb' }))
// 请求日志
app.use(requestLogger)
// 限流:登录接口 — 每分钟最多 10 次
const authLimiter = rateLimit({
windowMs: 60 * 1000,
@@ -153,6 +160,9 @@ app.use('/api/filters', apiLimiter, filterRoutes)
app.use('/api/admin', apiLimiter, adminRoutes)
app.use('/api/feedback', apiLimiter, feedbackRoutes)
app.use('/api/config', apiLimiter, configRoutes)
app.use('/api/track', apiLimiter, trackRoutes)
app.use('/api/logs', apiLimiter, logsRoutes)
app.use('/api/recurring', apiLimiter, recurringRoutes)
app.get('/api/health', async (_req, res) => {
try {
@@ -163,6 +173,12 @@ app.get('/api/health', async (_req, res) => {
}
})
// 全局错误处理中间件(兜底未捕获的异常)
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('[Global] Unhandled error:', err)
res.status(500).json({ code: 50000, message: '服务器内部错误' })
})
let server: any
// 生产环境:先备份再迁移,迁移出问题可恢复

View File

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

View File

@@ -124,9 +124,9 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
await conn.beginTransaction()
// 验证源分类存在且属于当前用户
// 验证源分类存在且属于当前用户(仅允许迁移自定义分类)
const [sourceRows] = await conn.query(
'SELECT id, type FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
'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]

View File

@@ -1,10 +1,10 @@
import { Router } from 'express'
import pool from '../db/connection'
import { authMiddleware } from '../middleware/auth'
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')
@@ -19,24 +19,25 @@ router.get('/', async (_req, res) => {
}
})
/** 允许修改的配置键白名单 */
const ALLOWED_KEYS = ['app_name', 'app_version', 'app_slogan', 'app_description']
/** 更新配置(管理员权限) */
router.put('/', authMiddleware, async (req, res) => {
router.put('/', requireAdmin, async (req, res) => {
try {
const userId = (req as any).userId
const [users] = await pool.execute('SELECT role FROM users WHERE id = ?', [userId])
const user = (users as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ error: '无权限' })
}
const data = req.body
if (!data || typeof data !== 'object') {
return res.status(400).json({ error: '无效的配置数据' })
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]

View File

@@ -1,6 +1,7 @@
import { Router } from 'express'
import pool from '../db/connection'
import { authMiddleware } from '../middleware/auth'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
@@ -30,16 +31,8 @@ router.post('/', authMiddleware, async (req, res) => {
})
/** 管理员获取反馈列表 */
router.get('/', authMiddleware, async (req, res) => {
router.get('/', requireAdmin, async (req, res) => {
try {
const userId = (req as any).userId
const [users] = await pool.execute('SELECT role FROM users WHERE id = ?', [userId])
const user = (users as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '无权限' })
}
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
@@ -59,14 +52,15 @@ router.get('/', authMiddleware, async (req, res) => {
)
const total = (countResult as any[])[0].total
// LIMIT/OFFSET 直接嵌入 SQLexecute 对参数类型要求严格)
const [rows] = await pool.execute(
`SELECT f.*, u.nickname, u.avatar_url
FROM feedbacks f
LEFT JOIN users u ON f.user_id = u.id
WHERE ${where}
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pageSize, offset]
LIMIT ${pageSize} OFFSET ${offset}`,
params
)
res.json({ code: 0, data: { list: rows, total, page, pageSize } })
@@ -77,16 +71,8 @@ router.get('/', authMiddleware, async (req, res) => {
})
/** 管理员更新反馈状态 */
router.put('/:id/status', authMiddleware, async (req, res) => {
router.put('/:id/status', requireAdmin, async (req, res) => {
try {
const userId = (req as any).userId
const [users] = await pool.execute('SELECT role FROM users WHERE id = ?', [userId])
const user = (users as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '无权限' })
}
const { id } = req.params
const { status, admin_reply } = req.body
@@ -102,6 +88,10 @@ router.put('/:id/status', authMiddleware, async (req, res) => {
)
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]

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

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

View File

@@ -46,35 +46,40 @@ const upload = multer({
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
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]
try {
const [rows] = await pool.query(
'SELECT type, group_id FROM notifications WHERE id = ?',
[req.params.id]
)
const group = (groupRows as any[])[0]
if (group && group.created_by === req.userId) {
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()
}
}
return res.status(403).json({ code: 40300, message: '需要管理员或群主权限' })
// 群组公告:检查是否是群主
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: '服务器错误' })
}
}
/** 获取通知列表 */
@@ -283,8 +288,8 @@ function isValidUrl(url: string): boolean {
const parsed = new URL(url)
return ['http:', 'https:'].includes(parsed.protocol)
} catch {
// 相对路径也允许
return url.startsWith('/') || url.startsWith('./')
// 非标准 URL 格式,小程序不支持相对路径,拒绝
return false
}
}

View File

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

View File

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

View File

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

View File

@@ -132,7 +132,11 @@ router.post('/avatar', (req: AuthRequest, res: Response) => {
if (oldUrl) {
const oldPath = path.join(AVATAR_DIR, oldUrl)
if (fs.existsSync(oldPath)) {
fs.unlinkSync(oldPath)
try {
fs.unlinkSync(oldPath)
} catch (e) {
console.warn('[User] 删除旧头像失败:', e)
}
}
}