Compare commits

..

35 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
44 changed files with 3758 additions and 371 deletions

1
DEV.md
View File

@@ -119,6 +119,7 @@ onPullDownRefresh(async () => {
- Never 使用 git stash - Never 使用 git stash
- Never 切换分支 - Never 切换分支
- Never 过度封装,保持代码简洁明了 - Never 过度封装,保持代码简洁明了
- Never 未确定需求就开始实现
## Requirements ## Requirements

View File

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

View File

@@ -24,6 +24,24 @@ export interface LogStats {
}> }>
} }
/** 埋点统计 */
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[]> { export function getLogFiles(): Promise<LogFileInfo[]> {
return request({ url: '/logs/files' }) return request({ url: '/logs/files' })
@@ -42,5 +60,32 @@ export function queryLogs(params: {
page?: number page?: number
pageSize?: number pageSize?: number
}): Promise<{ lines: string[]; total: number; page: number; pageSize: number }> { }): Promise<{ lines: string[]; total: number; page: number; pageSize: number }> {
return request({ url: `/logs/${params.date}`, data: { ...params, date: undefined } }) 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) { export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) {
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } }) return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } })
} }
/** 智能分类建议结果 */
export interface CategorySuggestion {
category: { id: number; name: string; color: string } | null
confidence: number
}
/** 根据金额和备注推荐分类 */
export function suggestCategory(amount: number, type: string, note?: string) {
return request<CategorySuggestion>({ url: '/stats/suggest-category', data: { amount, type, note } })
}

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

View File

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

View File

@@ -19,16 +19,26 @@
</view> </view>
</view> </view>
<view class="amount-display"> <AmountEditor
<text class="currency">¥</text> ref="amountEditorRef"
<text class="digits">{{ displayAmount }}</text> v-model="amountStr"
<text class="cursor">|</text> :fixed="true"
</view> size="large"
:auto-focus="true"
@focus="keyboardVisible = true"
@blur="keyboardVisible = false"
@confirm="save"
/>
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat"> <scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat" @tap="blurAmountEditor">
<view class="category-grid"> <view class="category-grid">
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" @tap="selectedCat = cat.id"> <view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id }" @tap="selectedCat = cat.id">
<view class="cat-icon-wrap">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" /> <CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
<view v-if="suggestedCatId === cat.id && selectedCat !== cat.id" class="suggest-badge">
<text class="suggest-badge-text">推荐</text>
</view>
</view>
<text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text> <text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text>
</view> </view>
</view> </view>
@@ -36,18 +46,23 @@
<view class="extra-fields"> <view class="extra-fields">
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange"> <picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
<view class="extra-row"> <view class="extra-row" @tap="blurAmountEditor">
<Icon name="calendar" :size="28" color="#8B7E7E" /> <Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="extra-text">{{ dateLabel }}</text> <text class="extra-text">{{ dateLabel }}</text>
</view> </view>
</picker> </picker>
<view class="extra-row"> <view class="extra-row">
<Icon name="edit" :size="28" color="#8B7E7E" /> <Icon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" /> <input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="blurAmountEditor" />
</view> </view>
</view> </view>
<Numpad v-model="amountStr" @confirm="save" /> <!-- 底部保存按钮键盘收起时显示 -->
<view class="save-bar" v-if="!showSuccess && !keyboardVisible">
<view class="save-btn" @tap="save">
<text class="save-btn-text">{{ editId ? '保存修改' : '保存' }}</text>
</view>
</view>
<SaveSuccess <SaveSuccess
v-model:visible="showSuccess" v-model:visible="showSuccess"
@@ -65,7 +80,8 @@ import { onShow } from '@dcloudio/uni-app'
import { useCategoryStore } from '@/stores/category' import { useCategoryStore } from '@/stores/category'
import { useTransactionStore } from '@/stores/transaction' import { useTransactionStore } from '@/stores/transaction'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import Numpad from '@/components/Numpad/Numpad.vue' import { suggestCategory } from '@/api/stats'
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue' import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
@@ -78,6 +94,9 @@ const type = ref<'expense' | 'income'>('expense')
const amountStr = ref('0') const amountStr = ref('0')
const selectedCat = ref<number | null>(null) const selectedCat = ref<number | null>(null)
const note = ref('') const note = ref('')
const suggestedCatId = ref<number | null>(null)
const suggestConfidence = ref(0)
let suggestTimer: ReturnType<typeof setTimeout> | null = null
const now = new Date() const now = new Date()
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const selectedDate = ref(localToday) const selectedDate = ref(localToday)
@@ -86,6 +105,9 @@ const editId = ref<number | null>(null)
const showSuccess = ref(false) const showSuccess = ref(false)
const scrollToCat = ref('') const scrollToCat = ref('')
const todayStr = localToday const todayStr = localToday
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const keyboardVisible = ref(false)
// Remember last selected category per type // Remember last selected category per type
const lastCatByType: Record<string, number | null> = { expense: null, income: null } const lastCatByType: Record<string, number | null> = { expense: null, income: null }
@@ -94,10 +116,6 @@ const dateLabel = computed(() => {
return `${d.getMonth() + 1}${d.getDate()}` return `${d.getMonth() + 1}${d.getDate()}`
}) })
const displayAmount = computed(() => {
if (!amountStr.value) return ''
return amountStr.value
})
const currentCategories = computed(() => catStore.getByType(type.value)) const currentCategories = computed(() => catStore.getByType(type.value))
@@ -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 () => { onMounted(async () => {
await waitForReady() await waitForReady()
@@ -136,7 +183,7 @@ onMounted(async () => {
} }
const pages = getCurrentPages() const pages = getCurrentPages()
const page = pages[pages.length - 1] as any const page = pages[pages.length - 1] as { options?: Record<string, string> }
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) { if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {
type.value = page.options.type type.value = page.options.type
} }
@@ -157,6 +204,7 @@ onMounted(async () => {
// 等待分类列表渲染完成后滚动到选中项 // 等待分类列表渲染完成后滚动到选中项
nextTick(() => { nextTick(() => {
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200) setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
amountEditorRef.value?.focus()
}) })
} else { } else {
uni.showToast({ title: '记录不存在', icon: 'none' }) uni.showToast({ title: '记录不存在', icon: 'none' })
@@ -179,6 +227,10 @@ function onDateChange(e: any) {
selectedDate.value = e.detail.value selectedDate.value = e.detail.value
} }
function blurAmountEditor() {
amountEditorRef.value?.blur()
}
async function save() { async function save() {
if (saving.value) return if (saving.value) return
const amount = Math.round(parseFloat(amountStr.value || '0') * 100) const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
@@ -223,6 +275,7 @@ function onContinue() {
amountStr.value = '' amountStr.value = ''
note.value = '' note.value = ''
showSuccess.value = false showSuccess.value = false
nextTick(() => amountEditorRef.value?.focus())
} }
/** 返回上一页 */ /** 返回上一页 */
@@ -252,10 +305,51 @@ function goBack() {
@import '@/styles/mixins.scss'; @import '@/styles/mixins.scss';
.page { .page {
@include page-base; min-height: 100vh;
background: $bg;
display: flex; display: flex;
flex-direction: column; 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 { .header-fixed {
@@ -320,38 +414,6 @@ function goBack() {
.income .slider { transform: translateX(160rpx); } .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 { .category-scroll {
max-height: 320rpx; max-height: 320rpx;
@@ -370,8 +432,38 @@ function goBack() {
align-items: center; align-items: center;
gap: 6rpx; gap: 6rpx;
padding: 12rpx 0; padding: 12rpx 0;
position: relative;
&:active { transform: scale(0.95); } &:active { transform: scale(0.95); }
&.suggested {
.cat-icon-wrap {
border: 3rpx dashed $primary;
border-radius: $radius-lg;
padding: 6rpx;
margin: -6rpx;
}
}
}
.cat-icon-wrap {
position: relative;
transition: all $transition-normal;
}
.suggest-badge {
position: absolute;
top: -8rpx;
right: -12rpx;
padding: 2rpx 10rpx;
background: $primary;
border-radius: $radius-xs;
}
.suggest-badge-text {
font-size: 18rpx;
color: $surface;
font-weight: 600;
} }
.cat-name { font-size: $font-sm; color: $text-sec; } .cat-name { font-size: $font-sm; color: $text-sec; }
@@ -404,10 +496,6 @@ function goBack() {
.placeholder { color: $text-muted; } .placeholder { color: $text-muted; }
@media (prefers-reduced-motion: reduce) {
.cursor {
animation: none;
opacity: 1;
}
}
</style> </style>

View File

@@ -52,13 +52,13 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<view class="actions"> <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> <text class="action-text">标记处理</text>
</view> </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> <text class="action-text">忽略</text>
</view> </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> <text class="action-text">重新打开</text>
</view> </view>
</view> </view>
@@ -77,6 +77,39 @@
<text class="empty-text">暂无反馈</text> <text class="empty-text">暂无反馈</text>
</view> </view>
</scroll-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> </view>
</template> </template>
@@ -86,6 +119,7 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback' import { getFeedbackList, updateFeedbackStatus } from '@/api/feedback'
import type { Feedback } from '@/api/feedback'
import { API_BASE } from '@/config' import { API_BASE } from '@/config'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
@@ -102,7 +136,7 @@ const statusMap: Record<string, string> = {
ignored: '已忽略' ignored: '已忽略'
} }
const list = ref<any[]>([]) const list = ref<Feedback[]>([])
const loading = ref(false) const loading = ref(false)
const filterStatus = ref('') const filterStatus = ref('')
const page = ref(1) const page = ref(1)
@@ -110,6 +144,12 @@ const pageSize = 20
const total = ref(0) const total = ref(0)
const initialLoaded = ref(false) 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) const noMore = computed(() => list.value.length >= total.value && total.value > 0)
onMounted(async () => { onMounted(async () => {
@@ -138,7 +178,7 @@ async function loadList(reset = false) {
} }
loading.value = true loading.value = true
try { 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 if (filterStatus.value) params.status = filterStatus.value
const data = await getFeedbackList(params) const data = await getFeedbackList(params)
if (reset) { if (reset) {
@@ -160,7 +200,35 @@ function loadMore() {
loadList(false) 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 { try {
await updateFeedbackStatus(item.id, status) await updateFeedbackStatus(item.id, status)
item.status = status item.status = status
@@ -420,4 +488,128 @@ function goBack() { uni.navigateBack() }
font-size: $font-md; font-size: $font-md;
color: $text-muted; 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> </style>

View File

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

View File

@@ -53,10 +53,12 @@
<view class="query-section"> <view class="query-section">
<view class="query-header"> <view class="query-header">
<text class="section-title">日志查询</text> <text class="section-title">日志查询</text>
<view class="date-picker" @tap="showDatePicker = true"> <picker mode="date" :value="selectedDate" @change="onDateChange">
<view class="date-picker">
<Icon name="calendar" :size="28" color="#8B7E7E" /> <Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="date-text">{{ selectedDate }}</text> <text class="date-text">{{ selectedDate }}</text>
</view> </view>
</picker>
</view> </view>
<!-- 筛选条件 --> <!-- 筛选条件 -->
@@ -67,7 +69,12 @@
<view class="filter-tab" :class="{ active: filterLevel === 'warn' }" @tap="filterLevel = 'warn'">警告</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 class="filter-tab" :class="{ active: filterLevel === 'info' }" @tap="filterLevel = 'info'">信息</view>
</view> </view>
<input class="search-input" v-model="keyword" placeholder="搜索关键词" @confirm="loadLogs" /> <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> </view>
<!-- 日志列表 --> <!-- 日志列表 -->
@@ -173,7 +180,12 @@ function loadMore() {
function selectDate(date: string) { function selectDate(date: string) {
selectedDate.value = date selectedDate.value = date
loadLogs() // loadLogs() 由 watch([filterLevel, selectedDate]) 统一触发,避免双重请求
}
function onDateChange(e: any) {
selectedDate.value = e.detail.value
// loadLogs() 由 watch 统一触发
} }
function getBarWidth(count: number): number { function getBarWidth(count: number): number {
@@ -189,7 +201,10 @@ function getLineClass(line: string): string {
} }
// 筛选条件变化时重新加载 // 筛选条件变化时重新加载
watch([filterLevel], () => loadLogs()) watch([filterLevel, selectedDate], () => {
page.value = 1
loadLogs()
})
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()
@@ -370,6 +385,40 @@ function goBack() {
margin-bottom: $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 { .filter-tabs {
display: flex; display: flex;
gap: $space-xs; gap: $space-xs;
@@ -391,16 +440,6 @@ function goBack() {
} }
} }
.search-input {
flex: 1;
height: 64rpx;
padding: 0 $space-md;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
font-size: $font-md;
}
.log-list { .log-list {
height: calc(100vh - 700rpx); height: calc(100vh - 700rpx);
background: $surface; background: $surface;

View File

@@ -586,6 +586,7 @@ onReachBottom(() => {
border-radius: $radius-2xl $radius-2xl 0 0; border-radius: $radius-2xl $radius-2xl 0 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
} }
.modal-header { .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 loading.value = true
try { try {
const params: any = { page: page.value, pageSize: 20 } const params: { page: number; pageSize: number; keyword?: string } = { page: page.value, pageSize: 20 }
if (keyword.value.trim()) params.keyword = keyword.value.trim() if (keyword.value.trim()) params.keyword = keyword.value.trim()
const data = await getUsers(params) const data = await getUsers(params)
if (reset) { if (reset) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,7 @@
</view> </view>
<view class="notif-content"> <view class="notif-content">
<view class="notif-title-row"> <view class="notif-title-row">
<text v-if="item.is_pinned" class="pin-badge">📌</text> <text v-if="item.is_pinned" class="pin-badge">置顶</text>
<text class="notif-title">{{ item.title }}</text> <text class="notif-title">{{ item.title }}</text>
</view> </view>
<text class="notif-body" v-if="item.content">{{ item.content }}</text> <text class="notif-body" v-if="item.content">{{ item.content }}</text>
@@ -355,7 +355,12 @@ onReachBottom(() => {
} }
.pin-badge { .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 { .notif-title {
@@ -435,6 +440,7 @@ onReachBottom(() => {
border-radius: $radius-2xl $radius-2xl 0 0; border-radius: $radius-2xl $radius-2xl 0 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
} }
.detail-header { .detail-header {

View File

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

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; border-radius: 6rpx;
background: linear-gradient(90deg, $primary, #FFB89A); background: linear-gradient(90deg, $primary, #FFB89A);
transition: width 0.6s ease-out; transition: width 0.6s ease-out;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
} }
.trend-amount { .trend-amount {

View File

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

View File

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

View File

@@ -47,6 +47,7 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
url: API_BASE + options.url, url: API_BASE + options.url,
method: options.method || 'GET', method: options.method || 'GET',
data: options.data, data: options.data,
timeout: 15000, // 15秒超时
header: { header: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}) ...(token ? { Authorization: `Bearer ${token}` } : {})

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

View File

@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS transactions (
note VARCHAR(200), note VARCHAR(200),
date DATE NOT NULL, date DATE NOT NULL,
group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)', group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)',
recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_date (user_id, date), INDEX idx_user_date (user_id, date),
@@ -123,7 +124,7 @@ CREATE TABLE IF NOT EXISTS feedbacks (
content TEXT NOT NULL COMMENT '反馈内容', content TEXT NOT NULL COMMENT '反馈内容',
contact VARCHAR(100) DEFAULT '' COMMENT '联系方式', contact VARCHAR(100) DEFAULT '' COMMENT '联系方式',
status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态', status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态',
admin_reply TEXT DEFAULT '' COMMENT '管理员回复', admin_reply TEXT COMMENT '管理员回复',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id), INDEX idx_user (user_id),
@@ -137,3 +138,35 @@ CREATE TABLE IF NOT EXISTS sys_config (
config_value TEXT NOT NULL COMMENT '配置值', config_value TEXT NOT NULL COMMENT '配置值',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) 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

@@ -23,6 +23,7 @@ import feedbackRoutes from './routes/feedback'
import configRoutes from './routes/config' import configRoutes from './routes/config'
import trackRoutes from './routes/track' import trackRoutes from './routes/track'
import logsRoutes from './routes/logs' import logsRoutes from './routes/logs'
import recurringRoutes from './routes/recurring'
import { backupDatabase } from './utils/backup' import { backupDatabase } from './utils/backup'
// Warn if token secret is using default fallback // Warn if token secret is using default fallback
@@ -159,8 +160,9 @@ app.use('/api/filters', apiLimiter, filterRoutes)
app.use('/api/admin', apiLimiter, adminRoutes) app.use('/api/admin', apiLimiter, adminRoutes)
app.use('/api/feedback', apiLimiter, feedbackRoutes) app.use('/api/feedback', apiLimiter, feedbackRoutes)
app.use('/api/config', apiLimiter, configRoutes) app.use('/api/config', apiLimiter, configRoutes)
app.use('/api/track', trackRoutes) app.use('/api/track', apiLimiter, trackRoutes)
app.use('/api/logs', apiLimiter, logsRoutes) app.use('/api/logs', apiLimiter, logsRoutes)
app.use('/api/recurring', apiLimiter, recurringRoutes)
app.get('/api/health', async (_req, res) => { app.get('/api/health', async (_req, res) => {
try { try {
@@ -171,6 +173,12 @@ app.get('/api/health', async (_req, res) => {
} }
}) })
// 全局错误处理中间件(兜底未捕获的异常)
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('[Global] Unhandled error:', err)
res.status(500).json({ code: 50000, message: '服务器内部错误' })
})
let server: any let server: any
// 生产环境:先备份再迁移,迁移出问题可恢复 // 生产环境:先备份再迁移,迁移出问题可恢复

View File

@@ -8,13 +8,13 @@ if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true }) fs.mkdirSync(LOG_DIR, { recursive: true })
} }
/** 获取当前日期字符串 YYYY-MM-DD */ /** 获取当前日期字符串 YYYY-MM-DD(服务器本地时间) */
function getDateStr(): string { function getDateStr(): string {
const d = new Date() const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} }
/** 格式化时间 HH:mm:ss.SSS */ /** 格式化时间 HH:mm:ss.SSS(服务器本地时间) */
function formatTime(date: Date): string { 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')}` 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')}`
} }
@@ -23,9 +23,11 @@ function formatTime(date: Date): string {
function writeLog(level: string, message: string, meta?: Record<string, any>) { function writeLog(level: string, message: string, meta?: Record<string, any>) {
const dateStr = getDateStr() const dateStr = getDateStr()
const logFile = path.join(LOG_DIR, `${dateStr}.log`) const logFile = path.join(LOG_DIR, `${dateStr}.log`)
const time = formatTime(new Date()) const now = new Date()
const time = formatTime(now)
const timestamp = now.toISOString() // 记录 UTC 时间戳,前端可按用户时区显示
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const line = `[${time}] ${level} ${message}${metaStr}\n` const line = `[${time}] [${timestamp}] ${level} ${message}${metaStr}\n`
fs.appendFile(logFile, line, (err) => { fs.appendFile(logFile, line, (err) => {
if (err) console.error('[Logger] Write error:', err) if (err) console.error('[Logger] Write error:', err)
@@ -37,13 +39,12 @@ export function requestLogger(req: Request, res: Response, next: NextFunction) {
const startTime = Date.now() const startTime = Date.now()
const { method, url, ip } = req const { method, url, ip } = req
// 提取用户 ID如果有 auth 中间件设置 // 响应结束时记录日志(此时 authMiddleware 已执行userId 可用
const userId = (req as any).userId
// 响应结束时记录日志
res.on('finish', () => { res.on('finish', () => {
const duration = Date.now() - startTime const duration = Date.now() - startTime
const { statusCode } = res const { statusCode } = res
// 延迟获取 userId确保 authMiddleware 已执行
const userId = (req as any).userId
const meta = { const meta = {
method, method,

View File

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

View File

@@ -1,10 +1,10 @@
import { Router } from 'express' import { Router } from 'express'
import pool from '../db/connection' import pool from '../db/connection'
import { authMiddleware } from '../middleware/auth' import { requireAdmin } from '../middleware/requireAdmin'
const router = Router() const router = Router()
/** 获取公开配置(无需管理员权限) */ /** 获取公开配置(经过全局 authMiddleware无需管理员权限) */
router.get('/', async (_req, res) => { router.get('/', async (_req, res) => {
try { try {
const [rows] = await pool.execute('SELECT config_key, config_value FROM sys_config') 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 { 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 const data = req.body
if (!data || typeof data !== 'object') { 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)) { 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( await pool.query(
'INSERT INTO sys_config (config_key, config_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE config_value = ?', 'INSERT INTO sys_config (config_key, config_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE config_value = ?',
[key, value, value] [key, value, value]

View File

@@ -1,6 +1,7 @@
import { Router } from 'express' import { Router } from 'express'
import pool from '../db/connection' import pool from '../db/connection'
import { authMiddleware } from '../middleware/auth' import { authMiddleware } from '../middleware/auth'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router() 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 { 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 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 pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20))
const status = req.query.status as string const status = req.query.status as string
@@ -59,14 +52,15 @@ router.get('/', authMiddleware, async (req, res) => {
) )
const total = (countResult as any[])[0].total const total = (countResult as any[])[0].total
// LIMIT/OFFSET 直接嵌入 SQLexecute 对参数类型要求严格)
const [rows] = await pool.execute( const [rows] = await pool.execute(
`SELECT f.*, u.nickname, u.avatar_url `SELECT f.*, u.nickname, u.avatar_url
FROM feedbacks f FROM feedbacks f
LEFT JOIN users u ON f.user_id = u.id LEFT JOIN users u ON f.user_id = u.id
WHERE ${where} WHERE ${where}
ORDER BY f.created_at DESC ORDER BY f.created_at DESC
LIMIT ? OFFSET ?`, LIMIT ${pageSize} OFFSET ${offset}`,
[...params, pageSize, offset] params
) )
res.json({ code: 0, data: { list: rows, total, page, pageSize } }) 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 { 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 { id } = req.params
const { status, admin_reply } = req.body const { status, admin_reply } = req.body
@@ -102,6 +88,10 @@ router.put('/:id/status', authMiddleware, async (req, res) => {
) )
const feedback = (feedbackRows as any[])[0] const feedback = (feedbackRows as any[])[0]
if (!feedback) {
return res.status(404).json({ code: 40400, message: '反馈不存在' })
}
await pool.execute( await pool.execute(
'UPDATE feedbacks SET status = ?, admin_reply = ? WHERE id = ?', 'UPDATE feedbacks SET status = ?, admin_reply = ? WHERE id = ?',
[status, admin_reply || '', id] [status, admin_reply || '', id]

View File

@@ -1,11 +1,18 @@
import { Router } from 'express' import { Router } from 'express'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import pool from '../db/connection'
import { requireAdmin } from '../middleware/requireAdmin' import { requireAdmin } from '../middleware/requireAdmin'
const router = Router() const router = Router()
const LOG_DIR = path.resolve(process.env.LOG_DIR || './logs') 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天 */ /** 获取日志列表最近7天 */
router.get('/files', requireAdmin, (_req, res) => { router.get('/files', requireAdmin, (_req, res) => {
try { try {
@@ -44,8 +51,7 @@ router.get('/stats', requireAdmin, (_req, res) => {
return res.json({ code: 0, data: { today: { total: 0, errors: 0, slow: 0 }, files: [] } }) return res.json({ code: 0, data: { today: { total: 0, errors: 0, slow: 0 }, files: [] } })
} }
const today = new Date() const dateStr = getTodayStr()
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
const todayFile = path.join(LOG_DIR, `${dateStr}.log`) const todayFile = path.join(LOG_DIR, `${dateStr}.log`)
let todayStats = { total: 0, errors: 0, slow: 0, warns: 0 } let todayStats = { total: 0, errors: 0, slow: 0, warns: 0 }
@@ -91,7 +97,88 @@ router.get('/stats', requireAdmin, (_req, res) => {
} }
}) })
/** 查询指定日期的日志 */ /** 获取埋点统计(必须在 /: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) => { router.get('/:date', requireAdmin, (req, res) => {
try { try {
const { date } = req.params const { date } = req.params
@@ -111,8 +198,9 @@ router.get('/:date', requireAdmin, (req, res) => {
let lines = content.split('\n').filter(l => l.trim()) let lines = content.split('\n').filter(l => l.trim())
// 按级别过滤 // 按级别过滤
if (level && level !== 'all') { const levelStr = typeof level === 'string' ? level : ''
lines = lines.filter(l => l.includes(` ${level.toUpperCase()} `)) if (levelStr && levelStr !== 'all') {
lines = lines.filter(l => l.includes(` ${levelStr.toUpperCase()} `))
} }
// 按关键词过滤 // 按关键词过滤

View File

@@ -46,6 +46,7 @@ const upload = multer({
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */ /** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) { async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
try {
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT type, group_id FROM notifications WHERE id = ?', 'SELECT type, group_id FROM notifications WHERE id = ?',
[req.params.id] [req.params.id]
@@ -75,6 +76,10 @@ async function requireNotificationEditAuth(req: AuthRequest, res: Response, next
} }
return res.status(403).json({ code: 40300, message: '需要管理员或群主权限' }) 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) const parsed = new URL(url)
return ['http:', 'https:'].includes(parsed.protocol) return ['http:', 'https:'].includes(parsed.protocol)
} catch { } catch {
// 相对路径也允许 // 非标准 URL 格式,小程序不支持相对路径,拒绝
return url.startsWith('/') || url.startsWith('./') 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 export default router

View File

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

View File

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