diff --git a/client/src/api/admin.ts b/client/src/api/admin.ts index 73e5a38..bc4c138 100644 --- a/client/src/api/admin.ts +++ b/client/src/api/admin.ts @@ -54,3 +54,11 @@ export function updateUserRole(id: number, role: 'user' | 'admin') { export function deleteUser(id: number) { return request({ url: `/admin/users/${id}`, method: 'DELETE' }) } + +// 健康检查类型从 @/api/health 导入 +export type { HealthStatus } from '@/api/health' + +/** 获取服务健康状态(管理员) */ +export function getHealth() { + return request({ url: '/health' }) +} diff --git a/client/src/api/backup.ts b/client/src/api/backup.ts new file mode 100644 index 0000000..1c4dce9 --- /dev/null +++ b/client/src/api/backup.ts @@ -0,0 +1,38 @@ +import { request } from '@/utils/request' + +/** 备份记录 */ +export interface Backup { + id: number + name: string + size: number + status: 'pending' | 'completed' | 'failed' + created_at: string + downloadToken?: string +} + +/** 备份列表响应 */ +export interface BackupList { + list: Backup[] + total: number +} + +/** 获取备份列表 */ +export function getBackups(params?: { page?: number; pageSize?: number }) { + return request({ url: '/backup', data: params }) +} + +/** 创建备份 */ +export function createBackup() { + return request<{ id: number }>({ url: '/backup', method: 'POST' }) +} + +/** 删除备份 */ +export function deleteBackup(id: number) { + return request({ url: `/backup/${id}`, method: 'DELETE' }) +} + +/** 获取备份下载地址 */ +export function getBackupDownloadUrl(id: number, token: string): string { + const apiBase = typeof window !== 'undefined' ? '/api' : '' + return `${apiBase}/backup/${id}/download?token=${encodeURIComponent(token)}` +} diff --git a/client/src/api/export.ts b/client/src/api/export.ts new file mode 100644 index 0000000..d0e9a32 --- /dev/null +++ b/client/src/api/export.ts @@ -0,0 +1,32 @@ +import { request } from '@/utils/request' +import { API_BASE } from '@/config' + +/** 导出参数 */ +export interface ExportParams { + startDate: string + endDate: string + type?: 'expense' | 'income' | 'all' + format?: 'csv' | 'json' +} + +/** 请求服务端导出(返回流) */ +export function requestExport(params: ExportParams) { + return request<{ downloadUrl: string }>({ + url: '/export', + data: { ...params, format: params.format || 'csv' } + }) +} + +/** 获取服务端导出下载 URL */ +export function getExportUrl(params: ExportParams): string { + const query = new URLSearchParams() + query.set('startDate', params.startDate) + query.set('endDate', params.endDate) + if (params.type) query.set('type', params.type) + if (params.format) query.set('format', params.format) + + const token = uni.getStorageSync('xc:token') + if (token) query.set('token', token) + + return `${API_BASE}/export?${query.toString()}` +} diff --git a/client/src/api/feedback.ts b/client/src/api/feedback.ts index 6fe0c15..46fa7f4 100644 --- a/client/src/api/feedback.ts +++ b/client/src/api/feedback.ts @@ -29,6 +29,22 @@ export function getFeedbackList(params?: { page?: number; pageSize?: number; sta return request<{ list: Feedback[]; total: number; page: number; pageSize: number }>({ url: '/feedback', data: params }) } +/** 我的反馈条目(简化版) */ +export interface MyFeedback { + id: number + type: string + content: string + contact: string + status: 'pending' | 'processed' | 'ignored' + admin_reply?: string + created_at: string +} + +/** 获取我的反馈列表 */ +export function getMyFeedbacks(params?: { page?: number; pageSize?: number }) { + return request<{ list: MyFeedback[]; total: number; page: number; pageSize: number }>({ url: '/feedback/mine', data: params }) +} + /** 更新反馈状态(管理员) */ export function updateFeedbackStatus(id: number, status: Feedback['status'], admin_reply?: string) { return request({ url: `/feedback/${id}/status`, method: 'PUT', data: { status, admin_reply } }) diff --git a/client/src/api/health.ts b/client/src/api/health.ts new file mode 100644 index 0000000..1e9a691 --- /dev/null +++ b/client/src/api/health.ts @@ -0,0 +1,15 @@ +import { request } from '@/utils/request' + +/** 健康检查结果 */ +export interface HealthStatus { + status: 'ok' | 'degraded' | 'error' + uptime: number + db: 'connected' | 'disconnected' + version: string + timestamp: string +} + +/** 获取服务健康状态 */ +export function getHealth() { + return request({ url: '/health' }) +} diff --git a/client/src/api/stats.ts b/client/src/api/stats.ts index c16fe17..fcdbf5c 100644 --- a/client/src/api/stats.ts +++ b/client/src/api/stats.ts @@ -25,6 +25,7 @@ export interface CategoryStat { export interface TrendPoint { date: string amount: number + label?: string } /** 获取概览数据 */ @@ -33,13 +34,13 @@ export function getOverview(params?: { month?: string; startDate?: string; endDa } /** 获取分类统计 */ -export function getCategoryStats(month?: string, type: string = 'expense', group_id?: number | null) { - return request({ url: '/stats/category', data: { month, type, group_id } }) +export function getCategoryStats(month?: string, type: string = 'expense', group_id?: number | null, period?: 'week' | 'month' | 'year') { + return request({ url: '/stats/category', data: { month, type, group_id, period } }) } /** 获取趋势数据 */ -export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) { - return request({ url: '/stats/trend', data: { month, type, group_id } }) +export function getTrend(month?: string, type: string = 'expense', group_id?: number | null, period?: 'week' | 'month' | 'year') { + return request({ url: '/stats/trend', data: { month, type, group_id, period } }) } /** 智能分类建议结果 */ diff --git a/client/src/api/tag.ts b/client/src/api/tag.ts new file mode 100644 index 0000000..b8e87ba --- /dev/null +++ b/client/src/api/tag.ts @@ -0,0 +1,47 @@ +import { request } from '@/utils/request' + +/** 标签 */ +export interface Tag { + id: number + name: string + color: string + created_at: string +} + +/** 标签统计 */ +export interface TagStat { + tag_id: number + tag_name: string + tag_color: string + count: number + total_amount: number +} + +/** 获取标签列表 */ +export function getTags() { + return request({ url: '/tags' }) +} + +/** 创建标签 */ +export function createTag(data: { name: string; color: string }) { + return request<{ id: number }>({ url: '/tags', method: 'POST', data }) +} + +/** 更新标签 */ +export function updateTag(id: number, data: { name?: string; color?: string }) { + return request({ url: `/tags/${id}`, method: 'PUT', data }) +} + +/** 删除标签 */ +export function deleteTag(id: number) { + return request({ url: `/tags/${id}`, method: 'DELETE' }) +} + +/** 按标签统计 */ +export function getTagStats(params?: { + month?: string + period?: 'week' | 'month' | 'year' + type?: 'expense' | 'income' +}) { + return request({ url: '/tags/stats/by-tag', data: params }) +} diff --git a/client/src/api/transaction.ts b/client/src/api/transaction.ts index 94b3325..41f5632 100644 --- a/client/src/api/transaction.ts +++ b/client/src/api/transaction.ts @@ -15,6 +15,14 @@ export interface Transaction { group_id?: number | null creator_nickname?: string creator_avatar?: string + tags?: TagItem[] +} + +/** 标签简略信息 */ +export interface TagItem { + id: number + name: string + color: string } /** 交易列表响应 */ @@ -40,6 +48,7 @@ export interface TransactionParams { minAmount?: number maxAmount?: number keyword?: string + tagId?: number } /** 获取交易列表 */ @@ -60,6 +69,7 @@ export function createTransaction(data: { note?: string date: string group_id?: number | null + tagIds?: number[] }) { return request<{ id: number }>({ url: '/transactions', method: 'POST', data }) } @@ -71,6 +81,7 @@ export function updateTransaction(id: number, data: { category_id?: number note?: string date: string + tagIds?: number[] }) { return request({ url: `/transactions/${id}`, method: 'PUT', data }) } @@ -79,3 +90,16 @@ export function updateTransaction(id: number, data: { export function deleteTransaction(id: number) { return request({ url: `/transactions/${id}`, method: 'DELETE' }) } + +/** 导入交易记录 */ +export function importTransactions(data: { + transactions: Array<{ + date: string + type: 'expense' | 'income' + category_name: string + amount: number + note?: string + }> +}) { + return request<{ imported: number; skipped: number }>({ url: '/transactions/import', method: 'POST', data }) +} diff --git a/client/src/components/DragSortList/DragSortList.vue b/client/src/components/DragSortList/DragSortList.vue new file mode 100644 index 0000000..d6a682e --- /dev/null +++ b/client/src/components/DragSortList/DragSortList.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/client/src/components/Icon/Icon.vue b/client/src/components/Icon/Icon.vue index e45e62c..0fb9196 100644 --- a/client/src/components/Icon/Icon.vue +++ b/client/src/components/Icon/Icon.vue @@ -50,6 +50,7 @@ const iconMap: Record> = { }, check: { '#FFFFFF': '/static/icons/check-white.png', + '#FF8C69': '/static/icons/check-orange.png', }, trash: { '#FF8C69': '/static/icons/trash-orange.png', @@ -71,6 +72,25 @@ const iconMap: Record> = { '#8B7E7E': '/static/icons/settings-gray.png', '#FF8C69': '/static/icons/settings-orange.png', }, + tag: { + '#8B7E7E': '/static/icons/tag-gray.png', + '#FF8C69': '/static/icons/tag-orange.png', + '#BFB3B3': '/static/icons/tag-light.png', + }, + archive: { + '#7BC67E': '/static/icons/archive-green.png', + '#BFB3B3': '/static/icons/archive-light.png', + }, + download: { + '#5B9BD5': '/static/icons/download-blue.png', + }, + upload: { + '#FF8C69': '/static/icons/upload-orange.png', + }, + dragHandle: { + '#BFB3B3': '/static/icons/drag-handle-light.png', + '#8B7E7E': '/static/icons/drag-handle-gray.png', + }, } const iconSrc = computed(() => { diff --git a/client/src/pages.json b/client/src/pages.json index c9c900b..127e861 100644 --- a/client/src/pages.json +++ b/client/src/pages.json @@ -154,6 +154,29 @@ "navigationBarTitleText": "埋点统计", "enablePullDownRefresh": true } + }, + { + "path": "pages/tag-manage/index", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "标签管理", + "enablePullDownRefresh": true + } + }, + { + "path": "pages/backup-manage/index", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "数据备份", + "enablePullDownRefresh": true + } + }, + { + "path": "pages/data-import/index", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "数据导入" + } } ], "globalStyle": { diff --git a/client/src/pages/add/index.vue b/client/src/pages/add/index.vue index b43d5c6..6b4bafd 100644 --- a/client/src/pages/add/index.vue +++ b/client/src/pages/add/index.vue @@ -53,6 +53,18 @@ + + + + 选择标签(选填) + + + {{ getTagById(tId)?.name || '' }} + + + + + @@ -64,6 +76,33 @@ @continue="onContinue" @back="onBack" /> + + + + + 选择标签 + 最多选择 5 个标签 + + + + {{ tag.name }} + + + + 暂无标签,可在"我的-标签管理"中添加 + + + + 确定 + + + @@ -72,6 +111,7 @@ import { ref, computed, watch, onMounted, nextTick } from 'vue' import { onShow } from '@dcloudio/uni-app' import { useCategoryStore } from '@/stores/category' import { useTransactionStore } from '@/stores/transaction' +import { useTagStore } from '@/stores/tag' import { waitForReady } from '@/utils/app-ready' import { suggestCategory } from '@/api/stats' import AmountEditor from '@/components/AmountEditor/AmountEditor.vue' @@ -82,11 +122,14 @@ import { statusBarHeight, capsuleRight } from '@/utils/system' const catStore = useCategoryStore() const txStore = useTransactionStore() +const tagStore = useTagStore() const type = ref<'expense' | 'income'>('expense') const amountStr = ref('0') const selectedCat = ref(null) const note = ref('') +const selectedTagIds = ref([]) +const showTagPicker = ref(false) const suggestedCatId = ref(null) const suggestConfidence = ref(0) let suggestTimer: ReturnType | null = null @@ -173,6 +216,13 @@ onMounted(async () => { } } + // 加载标签列表 + try { + await tagStore.fetchTags() + } catch { + // 标签加载失败不影响主流程 + } + const pages = getCurrentPages() const page = pages[pages.length - 1] as { options?: Record } if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) { @@ -192,6 +242,7 @@ onMounted(async () => { lastCatByType[existing.type] = existing.category_id note.value = existing.note || '' selectedDate.value = existing.date.slice(0, 10) + selectedTagIds.value = existing.tags?.map(t => t.id) || [] // 等待分类列表渲染完成后滚动到选中项 nextTick(() => { setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200) @@ -211,7 +262,10 @@ onMounted(async () => { // 返回页面时刷新分类(可能新增了分类) const initialLoaded = ref(false) onShow(() => { - if (initialLoaded.value) catStore.fetchCategories() + if (initialLoaded.value) { + catStore.fetchCategories() + tagStore.fetchTags() + } }) function onDateChange(e: any) { @@ -241,7 +295,8 @@ async function save() { type: type.value, category_id: selectedCat.value, note: note.value, - date: selectedDate.value + date: selectedDate.value, + ...(selectedTagIds.value.length > 0 ? { tagIds: selectedTagIds.value } : {}) } if (editId.value) { await txStore.updateTransaction(editId.value, data) @@ -265,6 +320,7 @@ function onContinue() { // 重置表单,保留分类和日期 amountStr.value = '' note.value = '' + selectedTagIds.value = [] showSuccess.value = false nextTick(() => amountEditorRef.value?.focus()) } @@ -274,6 +330,28 @@ function onBack() { uni.navigateBack() } +function openTagPicker() { + blurAmountEditor() + showTagPicker.value = true +} + +function toggleTag(tagId: number) { + const idx = selectedTagIds.value.indexOf(tagId) + if (idx >= 0) { + selectedTagIds.value = selectedTagIds.value.filter(id => id !== tagId) + } else { + if (selectedTagIds.value.length >= 5) { + uni.showToast({ title: '最多选择 5 个标签', icon: 'none' }) + return + } + selectedTagIds.value = [...selectedTagIds.value, tagId] + } +} + +function getTagById(id: number) { + return tagStore.getById(id) +} + function goBack() { // 保存成功后直接返回,不弹确认框 if (showSuccess.value) return @@ -461,6 +539,99 @@ function goBack() { .placeholder { color: $text-muted; } +.tag-display { + flex: 1; + display: flex; + align-items: center; + min-width: 0; +} + +.tag-chips { + display: flex; + flex-wrap: wrap; + gap: 8rpx; +} + +.tag-chip { + padding: 4rpx 16rpx; + border-radius: $radius-xs; +} + +.tag-chip-text { + font-size: $font-sm; + font-weight: 500; +} + +/* 标签选择弹窗 */ +.modal-mask { + @include modal-mask; +} + +.tag-modal { + @include bottom-modal; + padding-bottom: calc($space-xl + env(safe-area-inset-bottom)); +} + +.tag-modal-title { + @include modal-title; + text-align: left; + padding: $space-lg $space-xl $space-xs; +} + +.tag-modal-hint { + font-size: $font-sm; + color: $text-muted; + padding: 0 $space-xl $space-md; +} + +.tag-modal-list { + max-height: 500rpx; + padding: 0 $space-xl; +} + +.tag-option { + display: flex; + align-items: center; + gap: $space-sm; + padding: $space-md 0; + border-bottom: 1rpx solid $border; + + &:last-child { border-bottom: none; } + &:active { background: $surface-warm; } +} + +.tag-option-dot { + width: 24rpx; + height: 24rpx; + border-radius: 50%; + flex-shrink: 0; +} + +.tag-option-name { + flex: 1; + font-size: $font-lg; + color: $text; +} + +.tag-empty { + padding: $space-2xl 0; + text-align: center; +} + +.tag-empty-text { + font-size: $font-md; + color: $text-muted; +} + +.tag-modal-btns { + padding: $space-md $space-xl; + border-top: 2rpx solid $border; +} + +.tag-modal-btn { + @include btn-primary; + width: 100%; +} diff --git a/client/src/pages/admin/index.vue b/client/src/pages/admin/index.vue index 0e948d7..ec63dae 100644 --- a/client/src/pages/admin/index.vue +++ b/client/src/pages/admin/index.vue @@ -59,6 +59,29 @@ + + + + 系统状态 + + + {{ health.status === 'ok' ? '正常' : health.status === 'degraded' ? '降级' : '异常' }} + + + + 数据库 + {{ health.db === 'connected' ? '已连接' : '断开' }} + + + 运行时间 + {{ formatUptime(health.uptime) }} + + + 版本 + {{ health.version }} + + + 管理功能 @@ -103,12 +126,15 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app' import { waitForReady } from '@/utils/app-ready' import { statusBarHeight } from '@/utils/system' import { getDashboard } from '@/api/admin' +import { getHealth } from '@/api/health' +import type { HealthStatus } from '@/api/health' import { formatAmount } from '@/utils/format' import Icon from '@/components/Icon/Icon.vue' import type { Dashboard } from '@/api/admin' const loading = ref(true) const dashboard = ref(null) +const health = ref(null) onMounted(async () => { await waitForReady() @@ -120,12 +146,20 @@ onMounted(async () => { } finally { loading.value = false } + + // 加载健康状态(不阻塞页面) + try { + health.value = await getHealth() + } catch { + // 健康检查失败不影响主流程 + } }) // 静默刷新 async function refreshDashboard() { try { dashboard.value = await getDashboard() + health.value = await getHealth() } catch {} } @@ -146,6 +180,16 @@ function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) } function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) } function goLogs() { uni.navigateTo({ url: '/pages/admin/logs' }) } function goTrack() { uni.navigateTo({ url: '/pages/admin/track' }) } + +function formatUptime(seconds: number): string { + if (!seconds) return '-' + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const mins = Math.floor((seconds % 3600) / 60) + if (days > 0) return `${days}天${hours}小时` + if (hours > 0) return `${hours}小时${mins}分` + return `${mins}分钟` +} diff --git a/client/src/pages/backup-manage/index.vue b/client/src/pages/backup-manage/index.vue new file mode 100644 index 0000000..f0d14e5 --- /dev/null +++ b/client/src/pages/backup-manage/index.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/client/src/pages/bills/index.vue b/client/src/pages/bills/index.vue index 9e53571..18987ab 100644 --- a/client/src/pages/bills/index.vue +++ b/client/src/pages/bills/index.vue @@ -17,6 +17,25 @@ + + + + + 全部标签 + + + + {{ tag.name }} + + + + 共 {{ total }} 笔 @@ -43,6 +62,8 @@ :hideDate="true" :showCreator="groupStore.isGroupMode" :currentUserId="userStore.userInfo?.id" + :disableSwipe="groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id" + :swipeHint="!swipeHintDismissed && isFirstDeletable(item)" @item-tap="onEdit(item)" @delete="onDelete(item)" /> @@ -91,6 +112,7 @@ import { ref, computed, onMounted } from 'vue' import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app' import { useUserStore } from '@/stores/user' import { useGroupStore } from '@/stores/group' +import { useTagStore } from '@/stores/tag' import { waitForReady } from '@/utils/app-ready' import { getTransactions, deleteTransaction } from '@/api/transaction' import TransactionItem from '@/components/TransactionItem/TransactionItem.vue' @@ -104,8 +126,10 @@ import type { FilterParams } from '@/api/filter' const userStore = useUserStore() const groupStore = useGroupStore() +const tagStore = useTagStore() const loading = ref(false) const filterType = ref('all') +const filterTagId = ref(null) const page = ref(1) const pageSize = 20 const txList = ref([]) @@ -113,6 +137,35 @@ const total = ref(0) const loadError = ref(false) let loadSeq = 0 // 请求序号,防止并发覆盖 +// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复 +const swipeHintDismissed = ref(false) +const HINT_KEY = 'xiaocai_swipe_hint_shown' + +function isFirstDeletable(item: Transaction): boolean { + if (swipeHintDismissed.value) return false + // 群组模式下只有自己的记录可删除 + if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) return false + // 找到第一个可删除的项 + for (const group of groupedTx.value) { + for (const tx of group.items) { + if (groupStore.isGroupMode && tx.user_id !== userStore.userInfo?.id) continue + return tx.id === item.id + } + } + return false +} + +// 动画播完后标记已显示 +try { + if (uni.getStorageSync(HINT_KEY)) { + swipeHintDismissed.value = true + } +} catch {} +setTimeout(() => { + swipeHintDismissed.value = true + try { uni.setStorageSync(HINT_KEY, '1') } catch {} +}, 3000) + // 高级筛选 const showFilter = ref(false) const advancedFilters = ref({}) @@ -145,6 +198,7 @@ const initialLoaded = ref(false) onMounted(async () => { await waitForReady() + tagStore.fetchTags().catch(() => {}) loadTx(true).finally(() => { initialLoaded.value = true }) }) @@ -174,6 +228,7 @@ async function loadTx(reset = false, silent = false) { if (af.minAmount) params.minAmount = af.minAmount if (af.maxAmount) params.maxAmount = af.maxAmount if (af.keyword) params.keyword = af.keyword + if (filterTagId.value) params.tagId = filterTagId.value try { loading.value = true loadError.value = false @@ -379,4 +434,51 @@ onPullDownRefresh(() => { .state-text { @include state-text; } + +/* 标签筛选条 */ +.tag-filter-scroll { + white-space: nowrap; + padding: 0 $space-xl $space-sm; +} + +.tag-filter-row { + display: inline-flex; + gap: $space-xs; +} + +.tag-filter-chip { + display: inline-flex; + align-items: center; + gap: 8rpx; + padding: $space-xs $space-md; + background: $surface; + border-radius: $radius-lg; + border: 2rpx solid $border; + flex-shrink: 0; + transition: all $transition-normal; + + &.active { + background: $primary-light; + border-color: $primary; + } + + &:active { opacity: 0.7; } +} + +.tag-filter-dot { + width: 16rpx; + height: 16rpx; + border-radius: 50%; + flex-shrink: 0; +} + +.tag-filter-text { + font-size: $font-sm; + color: $text-sec; + + .active & { + color: $primary; + font-weight: 500; + } +} diff --git a/client/src/pages/category-manage/index.vue b/client/src/pages/category-manage/index.vue index 589f360..065475a 100644 --- a/client/src/pages/category-manage/index.vue +++ b/client/src/pages/category-manage/index.vue @@ -19,7 +19,10 @@ - + + + + {{ cat.name }} @@ -126,6 +129,11 @@ const newName = ref('') const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4'] const selectedColor = ref(colorOptions[0]) +// 拖拽排序相关 +let dragStartY = 0 +let dragCurrentIndex = -1 +let dragItemEl: any = null + // 编辑相关 const showEditModal = ref(false) const editingCat = ref(null) @@ -270,6 +278,52 @@ async function onMigrateConfirm() { uni.showToast({ title: '操作失败', icon: 'none' }) } } + +/** 拖拽排序:开始 */ +function onDragStart(e: any) { + dragStartY = e.touches[0].clientY + dragItemEl = e.currentTarget?.parentElement + if (dragItemEl) { + const id = Number(dragItemEl.dataset?.id) + const idx = currentCategories.value.findIndex(c => c.id === id) + dragCurrentIndex = idx + dragItemEl.style?.opacity && (dragItemEl.style.opacity = '0.6') + } +} + +/** 拖拽排序:移动 */ +function onDragMove(e: any) { + // 视觉反馈由 CSS transition 处理,这里仅用于阻止页面滚动 +} + +/** 拖拽排序:结束 */ +function onDragEnd(e: any) { + if (dragItemEl) { + dragItemEl.style?.opacity && (dragItemEl.style.opacity = '1') + } + + const endY = e.changedTouches[0].clientY + const delta = endY - dragStartY + const threshold = 50 + + if (Math.abs(delta) > threshold && dragCurrentIndex >= 0) { + const direction = delta > 0 ? 1 : -1 + const newIndex = dragCurrentIndex + direction + const cats = [...currentCategories.value] + if (newIndex >= 0 && newIndex < cats.length) { + // 交换位置 + const temp = cats[dragCurrentIndex] + cats[dragCurrentIndex] = cats[newIndex] + cats[newIndex] = temp + // 保存排序 + const sortedIds = cats.map(c => c.id) + catStore.sortCategories(sortedIds).catch(() => {}) + } + } + + dragCurrentIndex = -1 + dragItemEl = null +} diff --git a/client/src/pages/feedback/index.vue b/client/src/pages/feedback/index.vue index 4c692c5..91dfb53 100644 --- a/client/src/pages/feedback/index.vue +++ b/client/src/pages/feedback/index.vue @@ -11,7 +11,15 @@ - + + + 提交反馈 + 我的反馈 + + + + + 反馈类型 @@ -59,13 +67,45 @@ 感谢您的反馈,我们会认真处理每一条意见 + + + + + + + + 暂无反馈记录 + + + + 加载中... + + + + 加载更多 + + diff --git a/client/src/pages/profile/index.vue b/client/src/pages/profile/index.vue index 6a2eedc..d2ef936 100644 --- a/client/src/pages/profile/index.vue +++ b/client/src/pages/profile/index.vue @@ -51,6 +51,10 @@ 分类管理 + + 标签管理 + + 周期账单 @@ -59,6 +63,10 @@ 数据导出 + + 数据导入 + + @@ -83,6 +91,10 @@ 管理后台 + + 数据备份 + + @@ -142,6 +154,16 @@ + + + 导出方式 + + 本地导出 + 服务端导出 + + 服务端导出支持大数据量,直接从服务器下载文件 + + {{ exportCount }} 条记录 @@ -227,6 +249,7 @@ const exportStartDate = ref(getLocalDateStr().replace(/-/g, '-').replace(/(\d+)$ const exportEndDate = ref(getLocalDateStr()) const exportType = ref<'all' | 'expense' | 'income'>('all') const exportFormat = ref<'csv' | 'json'>('csv') +const exportMode = ref<'local' | 'server'>('local') const exportCount = ref(0) const exportLoading = ref(false) @@ -305,6 +328,10 @@ function goCategoryManage() { uni.navigateTo({ url: '/pages/category-manage/index' }) } +function goTagManage() { + uni.navigateTo({ url: '/pages/tag-manage/index' }) +} + function goRecurring() { uni.navigateTo({ url: '/pages/recurring/index' }) } @@ -317,6 +344,14 @@ function goFeedback() { uni.navigateTo({ url: '/pages/feedback/index' }) } +function goDataImport() { + uni.navigateTo({ url: '/pages/data-import/index' }) +} + +function goBackupManage() { + uni.navigateTo({ url: '/pages/backup-manage/index' }) +} + function goProfileEdit() { uni.navigateTo({ url: '/pages/profile-edit/index' }) } @@ -416,6 +451,49 @@ function getLocalDateStr(): string { async function doExport() { if (exportCount.value === 0 || exportLoading.value) return + + // 服务端导出 + if (exportMode.value === 'server') { + exportLoading.value = true + try { + const { getExportUrl } = await import('@/api/export') + const url = getExportUrl({ + startDate: exportStartDate.value, + endDate: exportEndDate.value, + type: exportType.value, + format: exportFormat.value + }) + // #ifdef H5 + window.open(url, '_blank') + // #endif + // #ifdef MP-WEIXIN + uni.downloadFile({ + url, + success: (res) => { + if (res.statusCode === 200) { + const ext = exportFormat.value === 'json' ? 'json' : 'csv' + uni.openDocument({ + filePath: res.tempFilePath, + fileType: ext, + showMenu: true, + fail: () => uni.showToast({ title: '打开失败', icon: 'none' }) + }) + } + }, + fail: () => uni.showToast({ title: '下载失败', icon: 'none' }) + }) + // #endif + uni.showToast({ title: '导出成功', icon: 'success' }) + } catch { + uni.showToast({ title: '导出失败', icon: 'none' }) + } finally { + exportLoading.value = false + showExportPanel.value = false + } + return + } + + // 本地导出 exportLoading.value = true uni.showLoading({ title: '导出中 0%' }) try { @@ -965,4 +1043,11 @@ function saveFile(content: string, fileName: string, mimeType: string) { font-weight: 600; color: $surface; } + +.export-hint { + font-size: $font-sm; + color: $text-muted; + display: block; + margin-top: $space-sm; +} diff --git a/client/src/pages/stats/index.vue b/client/src/pages/stats/index.vue index b5bffa6..3b98082 100644 --- a/client/src/pages/stats/index.vue +++ b/client/src/pages/stats/index.vue @@ -22,6 +22,14 @@ + + + + + + + + {{ tabType === 'expense' ? '总支出' : '总收入' }} @@ -95,7 +103,7 @@ /> - {{ point.date.slice(8) }}日 + {{ point.label || (point.date.slice(8) + '日') }} @@ -139,6 +147,7 @@ const configStore = useConfigStore() const currentMonth = ref(getCurrentMonth()) const tabType = ref<'expense' | 'income'>('expense') +const period = ref<'week' | 'month' | 'year'>('month') const { overview, categoryStats, trendData } = storeToRefs(statsStore) const loading = ref(true) const loadError = ref(false) @@ -176,7 +185,7 @@ const categoryChartData = computed(() => { const trendChartData = computed(() => { if (trendData.value.length === 0) return null return { - categories: trendData.value.map(p => p.date.slice(8) + '日'), + categories: trendData.value.map(p => p.label || (p.date.slice(8) + '日')), series: [{ name: tabType.value === 'expense' ? '支出' : '收入', data: trendData.value.map(p => p.amount / 100) @@ -241,6 +250,8 @@ onShow(() => { watch(currentMonth, () => loadData()) // tab 切换时只请求分类和趋势数据 watch(tabType, () => loadTabData()) +// 周期切换时重新加载数据 +watch(period, () => loadData()) async function loadData(silent = false) { const seq = ++loadSeq @@ -249,8 +260,8 @@ async function loadData(silent = false) { loadError.value = false await Promise.all([ statsStore.fetchOverview(currentMonth.value), - statsStore.fetchCategoryStats(currentMonth.value, tabType.value), - statsStore.fetchTrend(currentMonth.value, tabType.value), + statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value), + statsStore.fetchTrend(currentMonth.value, tabType.value, period.value), fetchMaxSingle(), fetchPrevMonthData() ]) @@ -269,8 +280,8 @@ async function loadTabData() { const seq = ++loadSeq try { await Promise.all([ - statsStore.fetchCategoryStats(currentMonth.value, tabType.value), - statsStore.fetchTrend(currentMonth.value, tabType.value), + statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value), + statsStore.fetchTrend(currentMonth.value, tabType.value, period.value), fetchMaxSingle() ]) if (seq !== loadSeq) return @@ -339,6 +350,10 @@ function nextMonth() { currentMonth.value = m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}` } +function switchPeriod(p: 'week' | 'month' | 'year') { + period.value = p +} + function getBarWidth(amount: number) { const max = Math.max(...trendData.value.map(t => t.amount), 1) return (amount / max) * 100 @@ -409,6 +424,35 @@ function getBarWidth(amount: number) { } } +.period-wrap { + display: flex; + justify-content: center; + margin-bottom: $space-lg; +} + +.period-tabs { + display: inline-flex; + background: $surface-warm; + border-radius: $radius-lg; + padding: $space-xs; + gap: $space-xs; +} + +.period-tab { + padding: $space-xs $space-lg; + border-radius: $radius-lg; + font-size: $font-md; + color: $text-sec; + transition: all $transition-normal; + + &.active { + background: $surface; + color: $primary; + font-weight: 600; + box-shadow: $shadow-sm; + } +} + .overview-card, .chart-card { @include card($radius-2xl); margin: 0 $space-xl $space-lg; diff --git a/client/src/pages/tag-manage/index.vue b/client/src/pages/tag-manage/index.vue new file mode 100644 index 0000000..551efaf --- /dev/null +++ b/client/src/pages/tag-manage/index.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/client/src/stores/stats.ts b/client/src/stores/stats.ts index ecce02d..8dbe8ae 100644 --- a/client/src/stores/stats.ts +++ b/client/src/stores/stats.ts @@ -36,8 +36,8 @@ export const useStatsStore = defineStore('stats', () => { } } - async function fetchCategoryStats(month?: string, type: string = 'expense') { - const data = await api.getCategoryStats(month || getCurrentMonth(), type, getGroupId()) + async function fetchCategoryStats(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') { + const data = await api.getCategoryStats(month || getCurrentMonth(), type, getGroupId(), period) categoryStats.value = Array.isArray(data) ? data.map(item => ({ ...item, amount: Number(item.amount) || 0, @@ -45,8 +45,8 @@ export const useStatsStore = defineStore('stats', () => { })) : [] } - async function fetchTrend(month?: string, type: string = 'expense') { - const data = await api.getTrend(month || getCurrentMonth(), type, getGroupId()) + async function fetchTrend(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') { + const data = await api.getTrend(month || getCurrentMonth(), type, getGroupId(), period) trendData.value = Array.isArray(data) ? data.map(item => ({ ...item, amount: Number(item.amount) || 0 diff --git a/client/src/stores/tag.ts b/client/src/stores/tag.ts new file mode 100644 index 0000000..375576a --- /dev/null +++ b/client/src/stores/tag.ts @@ -0,0 +1,52 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import * as api from '@/api/tag' + +export const useTagStore = defineStore('tag', () => { + const tags = ref([]) + const loading = ref(false) + + /** 获取标签列表 */ + async function fetchTags() { + loading.value = true + try { + const data = await api.getTags() + tags.value = Array.isArray(data) ? data : [] + } catch { + tags.value = [] + } finally { + loading.value = false + } + } + + /** 创建标签 */ + async function addTag(data: { name: string; color: string }) { + const result = await api.createTag(data) + await fetchTags() + return result.id + } + + /** 更新标签 */ + async function updateTag(id: number, data: { name?: string; color?: string }) { + await api.updateTag(id, data) + await fetchTags() + } + + /** 删除标签 */ + async function deleteTag(id: number) { + await api.deleteTag(id) + await fetchTags() + } + + /** 根据 ID 获取标签 */ + function getById(id: number) { + return tags.value.find(t => t.id === id) + } + + /** 获取选中的标签 ID 对应的标签列表 */ + function getByIds(ids: number[]) { + return tags.value.filter(t => ids.includes(t.id)) + } + + return { tags, loading, fetchTags, addTag, updateTag, deleteTag, getById, getByIds } +}) diff --git a/docs/arch-alignment.md b/docs/arch-alignment.md new file mode 100644 index 0000000..89a275f --- /dev/null +++ b/docs/arch-alignment.md @@ -0,0 +1,577 @@ +# 小菜记账 增量架构设计文档 + +> 版本:v1.0 | 架构师:高见远(Gao) | 日期:2025-07 + +--- + +## Part A: 系统设计 + +### 1. 实现方案 + 框架选型 + +#### 1.1 分类拖拽排序(P0-1) + +**技术挑战**:微信小程序不支持 HTML5 Drag and Drop API,Uni-app 的 `movable-area`/`movable-view` 适合单元素拖拽而非列表重排。 + +**方案**:基于 Touch 事件的自定义拖拽排序组件 `DragSortList`。 + +- 监听 `@touchstart` / `@touchmove` / `@touchend` 三组事件 +- 拖拽时通过 `transform: translateY()` 实现元素位移动画 +- 拖拽过程中实时计算目标索引,交换数组元素位置 +- 松手后调用已就绪的 `sortCategories(ids)` API 批量更新排序 +- 支出/收入各自独立排序(复用现有 `tabType` 切换 + `currentCategories` computed) +- **不引入第三方拖拽库**——Uni-app 小程序环境下无成熟可用方案,自研可控性更高 + +#### 1.2 服务端导出流式响应(P2-3) + +**方案**:Node.js 原生 Stream + `pipeline()` 管道。 + +- **< 1 万条**:使用 `Readable.from()` 创建内存流,逐行 push 数据,通过 `pipeline()` 管道传输到 Response +- **≥ 1 万条**:先写入临时文件(`os.tmpdir()`),再 `fs.createReadStream()` 流式返回,完成后删除临时文件 +- CSV 格式:手动拼装(无需额外依赖),添加 BOM 头确保中文兼容 +- JSON 格式:流式写入 `[` + 逐条 `,` 分隔 + `]`,避免全量 JSON.stringify +- Response Headers:`Content-Type: text/csv; charset=utf-8` / `application/json; charset=utf-8`,`Content-Disposition: attachment; filename=...` +- 超时保护:流式响应不设 `express.json()` body 限制,但设 60s 超时 + +#### 1.3 标签系统数据库设计(P3-2~4) + +**设计原则**:标签不区分收支类型,跨分类使用,轻量关联表。 + +- `tags` 表:用户维度,每用户最多 20 个标签,8 色预设 +- `transaction_tags` 表:多对多关联,每笔交易最多 5 个标签 +- 标签与交易在同一事务中写入(`POST/PUT /transactions` 扩展 `tagIds` 字段) +- 查询交易列表时 LEFT JOIN 聚合标签信息(避免 N+1 查询) +- 统计按标签聚合使用 `GROUP BY tag_id` + +#### 1.4 数据导入(P2-2) + +**方案**:JSON 格式导入,服务端校验 + 批量写入 + 去重。 + +- 接收 JSON 数组,校验字段格式(必填:date, type, amount, category_name) +- 按 `date + type + amount + note` 组合去重(查现有记录比对) +- 使用 `INSERT ... VALUES (...), (...), ...` 批量写入(每批 100 条) +- 返回结果:`{ total, imported, skipped, errors[] }` + +#### 1.5 统计年度/周视图(P2-1) + +**方案**:后端 `period` 参数扩展 + 前端维度切换器。 + +- 后端:`GET /stats/category` 和 `GET /stats/trend` 新增 `period=week|month|year` 参数 + - `week`:基于当前日期计算本周一至本周日,趋势 X 轴为周一~周日 + - `month`:保持现有行为(默认) + - `year`:基于当前年份 1-12 月,趋势 X 轴为 1月~12月 +- 年视图日期范围计算:`startDate = ${year}-01-01`, `endDate = ${year}-12-31` +- 周视图日期范围计算:获取本周一和本周日 +- 趋势数据 GROUP BY 逻辑:年视图按月 GROUP,周视图按日 GROUP + +--- + +### 2. 文件列表及相对路径 + +#### 2.1 新建文件 + +| 文件路径 | 说明 | +|---------|------| +| `client/src/api/backup.ts` | 备份管理 API(下载备份) | +| `client/src/api/tag.ts` | 标签 CRUD + 统计 API | +| `client/src/api/export.ts` | 服务端导出 API | +| `client/src/api/health.ts` | 健康检查 API | +| `client/src/stores/tag.ts` | 标签 Pinia Store | +| `client/src/components/DragSortList/DragSortList.vue` | 拖拽排序列表组件 | +| `client/src/pages/backup-manage/index.vue` | 备份管理页面(仅管理员) | +| `client/src/pages/data-import/index.vue` | 数据导入页面 | +| `client/src/pages/tag-manage/index.vue` | 标签管理页面 | +| `server/src/routes/tag.ts` | 标签 CRUD + 按标签统计路由 | +| `server/src/routes/export.ts` | 服务端导出路由 | + +#### 2.2 修改文件 + +| 文件路径 | 主要变更 | +|---------|---------| +| `server/src/db/schema.sql` | 新增 tags、transaction_tags 表定义 | +| `server/src/db/init.ts` | 新增 tags、transaction_tags 表迁移 | +| `server/src/routes/feedback.ts` | 新增 `GET /mine` 端点(用户查看自己的反馈) | +| `server/src/routes/backup.ts` | 新增 `GET /:id/download` 端点(临时签名 URL) | +| `server/src/routes/stats.ts` | category/trend 端点增加 `period` 参数支持 | +| `server/src/routes/transaction.ts` | POST/PUT 支持 `tagIds`;GET 支持 `tagId` 筛选;GET/:id 返回 tags | +| `server/src/index.ts` | health 端点移到 authMiddleware 之前;注册 tag、export 路由 | +| `client/src/api/feedback.ts` | 新增 `getMyFeedbacks()` API 函数 | +| `client/src/api/stats.ts` | 修改 `getCategoryStats`、`getTrend` 支持 `period` 参数 | +| `client/src/api/transaction.ts` | 新增 `importTransactions()`;类型扩展 `tagIds`、`tagId` | +| `client/src/api/admin.ts` | 新增 `getHealth()` API 函数 | +| `client/src/stores/category.ts` | 无需修改(sortCategories 已就绪) | +| `client/src/stores/stats.ts` | fetchCategoryStats/fetchTrend 支持 period 参数 | +| `client/src/pages/category-manage/index.vue` | 集成 DragSortList 拖拽排序 | +| `client/src/pages/feedback/index.vue` | 新增"我的反馈"Tab | +| `client/src/pages/stats/index.vue` | 添加维度切换器(周/月/年) | +| `client/src/pages/profile/index.vue` | 导出面板增加"服务端导出"选项 | +| `client/src/pages/add/index.vue` | 添加标签选择入口 | +| `client/src/pages/bills/index.vue` | 添加标签筛选功能 | +| `client/src/pages/admin/index.vue` | 添加服务状态卡片 | +| `client/src/pages.json` | 注册新页面路由 | + +--- + +### 3. 数据结构和接口(类图) + +```mermaid +classDiagram + class Tag { + +int id + +int user_id + +string name + +string color + +string created_at + } + + class TransactionTag { + +int transaction_id + +int tag_id + } + + class Transaction { + +int id + +int user_id + +int amount + +string type + +int category_id + +string note + +string date + +int group_id + +int recurring_id + +Tag[] tags + } + + class Feedback { + +int id + +int user_id + +string type + +string content + +string contact + +string status + +string admin_reply + +string created_at + } + + class Category { + +int id + +int user_id + +string name + +string icon + +string color + +string type + +int sort_order + +int is_custom + } + + Transaction "1" --o "*" TransactionTag : has + Tag "1" --o "*" TransactionTag : referenced_by + Transaction ..> Category : belongs_to + + note for Tag "每用户最多 20 个标签\n8 色预设颜色选择器\n标签不区分收支类型" + note for TransactionTag "复合主键 (transaction_id, tag_id)\n每笔交易最多 5 个标签" +``` + +#### 3.1 新增数据库表 + +**tags 表** + +```sql +CREATE TABLE IF NOT EXISTS tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(20) NOT NULL COMMENT '标签名称', + color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_name (user_id, name), + INDEX idx_user (user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +**transaction_tags 表** + +```sql +CREATE TABLE IF NOT EXISTS transaction_tags ( + transaction_id INT NOT NULL, + tag_id INT NOT NULL, + PRIMARY KEY (transaction_id, tag_id), + INDEX idx_tag (tag_id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +#### 3.2 新增/修改 API 端点 + +##### 标签 CRUD + +| 方法 | 路径 | 请求 | 响应 | 说明 | +|------|------|------|------|------| +| GET | `/api/tags` | — | `{ list: Tag[] }` | 获取当前用户所有标签 | +| POST | `/api/tags` | `{ name, color }` | `{ id }` | 创建标签(限 20 个/用户) | +| PUT | `/api/tags/:id` | `{ name?, color? }` | — | 更新标签 | +| DELETE | `/api/tags/:id` | — | — | 删除标签(级联删除关联) | +| GET | `/api/stats/by-tag` | `?month&period&type` | `TagStat[]` | 按标签统计 | + +**TagStat 类型**: +```ts +{ id: number; name: string; color: string; amount: number; count: number } +``` + +##### 用户反馈列表 + +| 方法 | 路径 | 请求 | 响应 | 说明 | +|------|------|------|------|------| +| GET | `/api/feedback/mine` | `?page&pageSize` | `{ list, total, page, pageSize }` | 当前用户的反馈列表 | + +##### 备份下载 + +| 方法 | 路径 | 请求 | 响应 | 说明 | +|------|------|------|------|------| +| GET | `/api/backup/:id/download` | — | 文件流 | 下载备份文件(临时签名 URL,1h 有效) | + +**实现方式**:在 `backup` 表中无需存储,使用 HMAC-SHA256 生成带过期时间的签名 token,下载时校验签名。签名格式:`backupId:timestamp:hmac-sha256(backupId:timestamp)`。将签名作为 `?token=xxx` 传给前端,下载端点校验 token 有效性(无需认证中间件)。 + +##### 统计年度/周视图 + +| 方法 | 路径 | 请求参数 | 说明 | +|------|------|---------|------| +| GET | `/api/stats/category` | 新增 `period=week\|month\|year` | 周/月/年分类统计 | +| GET | `/api/stats/trend` | 新增 `period=week\|month\|year` | 周/月/年趋势数据 | + +**period 参数逻辑**: +- `week`(新增):`startDate` = 本周一,`endDate` = 本周日,趋势按日 GROUP +- `month`(默认):保持现有行为,趋势按日 GROUP +- `year`(新增):`startDate` = 当年1月1日,`endDate` = 当年12月31日,趋势按月 GROUP(`DATE_FORMAT(date, '%Y-%m')`) + +**年视图趋势响应**(新增 `label` 字段): +```ts +{ label: string; date: string; amount: number }[] +// 年视图 label = "1月"~"12月" +// 周视图 label = "周一"~"周日" +// 月视图 label = "1日"~"31日"(保持兼容) +``` + +##### 数据导入 + +| 方法 | 路径 | 请求 | 响应 | 说明 | +|------|------|------|------|------| +| POST | `/api/transactions/import` | `{ items: ImportItem[] }` | `{ total, imported, skipped, errors[] }` | 批量导入 | + +**ImportItem 类型**: +```ts +{ + date: string // 必填,YYYY-MM-DD + type: string // 必填,expense | income + amount: number // 必填,单位:分 + category_name: string // 可选,匹配不到则归入"未分类" + note?: string + tag_names?: string[] // 可选,按名称匹配标签 +} +``` + +##### 服务端导出 + +| 方法 | 路径 | 请求参数 | 响应 | 说明 | +|------|------|---------|------|------| +| GET | `/api/export` | `startDate, endDate, type, format=csv\|json` | 文件流 | 流式导出 | + +##### 健康检查(改造) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/health` | 移到 `authMiddleware` 之前,无需认证 | + +##### 交易记录(改造) + +| 方法 | 路径 | 变更 | +|------|------|------| +| POST | `/api/transactions` | 请求新增 `tagIds: number[]`(可选,最多 5 个) | +| PUT | `/api/transactions/:id` | 请求新增 `tagIds: number[]`(可选,最多 5 个,全量替换) | +| GET | `/api/transactions` | 新增 `tagId` 查询参数筛选 | +| GET | `/api/transactions/:id` | 响应新增 `tags: { id, name, color }[]` | + +--- + +### 4. 程序调用流程(时序图) + +#### 4.1 分类拖拽排序 + +```mermaid +sequenceDiagram + participant U as 用户 + participant P as category-manage + participant D as DragSortList + participant S as categoryStore + participant A as sortCategories API + participant B as PUT /categories/sort + + U->>P: 长按分类项进入排序模式 + P->>P: showSortMode = true + P->>D: 渲染 DragSortList (items=currentCategories) + U->>D: touchstart (记录起始位置) + U->>D: touchmove (计算偏移, 交换元素位置) + D->>D: 实时更新 items 数组顺序 + U->>D: touchend (拖拽结束) + D->>P: @change事件 (新顺序ids) + P->>S: sortCategories(newIds) + S->>A: sortCategories(ids) + A->>B: PUT /categories/sort { ids } + B-->>A: { code: 0 } + A-->>S: 成功 + S->>S: fetchCategories() 刷新 +``` + +#### 4.2 数据导入 + +```mermaid +sequenceDiagram + participant U as 用户 + participant P as data-import + participant A as transaction API + participant S as POST /transactions/import + participant DB as MySQL + + U->>P: 选择 JSON 文件 + P->>P: uni.chooseFile / 读取文件内容 + P->>P: 解析 JSON,显示预览(条数、日期范围) + U->>P: 确认导入 + P->>A: importTransactions(items) + A->>S: POST /transactions/import { items } + S->>S: 校验每条记录格式 + S->>DB: 查询已有记录 (去重比对) + S->>DB: 批量 INSERT (每批100条) + S->>DB: 写入 transaction_tags (如有 tag_names) + S-->>A: { total, imported, skipped, errors } + A-->>P: 导入结果 + P->>U: 显示导入结果(成功X条, 跳过Y条) +``` + +#### 4.3 标签关联交易 + +```mermaid +sequenceDiagram + participant U as 用户 + participant P as add/index + participant TS as tagStore + participant TA as transaction API + participant S as POST /transactions + participant DB as MySQL + + U->>P: 点击"添加标签" + P->>P: 显示标签选择面板(已有标签 + 新建入口) + U->>P: 选择标签(最多5个) + P->>P: selectedTagIds 更新 + U->>P: 保存交易 + P->>TA: createTransaction({...data, tagIds}) + TA->>S: POST /transactions { amount, type, ..., tagIds } + S->>S: 校验 tagIds (≤5, 属于当前用户) + S->>DB: INSERT INTO transactions + S->>DB: INSERT INTO transaction_tags (批量) + S-->>TA: { id } + TA-->>P: 成功 + + Note over P,U: 编辑交易时同理,PUT 全量替换 tagIds +``` + +#### 4.4 服务端导出 + +```mermaid +sequenceDiagram + participant U as 用户 + participant P as profile/index + participant A as export API + participant S as GET /api/export + participant DB as MySQL + + U->>P: 点击"服务端导出" + P->>A: serverExport(params) + A->>S: GET /api/export?startDate=&endDate=&type=&format= + S->>DB: SELECT COUNT(*) (判断数量) + alt 记录 < 10000 + S->>DB: 流式查询 (cursor) + S->>S: Readable.from() 逐行生成 CSV/JSON + S-->>P: 流式响应 (Transfer-Encoding: chunked) + else 记录 ≥ 10000 + S->>DB: 流式查询写入临时文件 + S->>S: fs.createReadStream() + S-->>P: 流式响应 + S->>S: 删除临时文件 + end + P->>U: 保存文件到本地 +``` + +--- + +### 5. 待明确事项 + +| # | 事项 | 当前假设 | +|---|------|---------| +| 1 | 备份下载的签名 URL 是通过新端点直接下载,还是返回签名 URL 让前端跳转? | 采用直接下载方式:`GET /api/backup/:id/download?token=xxx`,后端校验 token 后流式返回文件 | +| 2 | 年视图/周视图的 `month` 参数如何处理? | 新增 `period` 参数,当 `period=year` 时 `month` 参数被忽略,使用当前年份;当 `period=week` 时使用当前周 | +| 3 | 导入 JSON 的 `category_name` 匹配不到时如何处理? | 归入"未分类"(category_id = NULL),不自动创建分类 | +| 4 | 标签预设 8 色具体色值? | 复用现有 `colorOptions`:`#FF8C69, #7BC67E, #5B9BD5, #FFD700, #FF69B4, #8B5CF6, #F97316, #06B6D4` | +| 5 | 健康检查移到 authMiddleware 前是否影响安全? | `/api/health` 仅返回 DB 连接状态和服务器时间,不泄露敏感信息,安全无影响 | +| 6 | 数据导入文件大小限制? | 前端限制 5MB,后端 `express.json({ limit: '5mb' })` 仅对导入端点放宽(当前全局 10kb) | + +--- + +## Part B: 任务分解 + +### 6. 需要新增的依赖包 + +#### 后端 (server) + +``` +无新增依赖 +``` + +> 流式导出使用 Node.js 内置 `stream`、`fs`、`os` 模块,CSV 拼装手写无需 `json2csv`,签名 URL 使用已有 `crypto` 模块。 + +#### 前端 (client) + +``` +无新增依赖 +``` + +> 拖拽排序使用 Touch 事件自研实现,不引入第三方拖拽库。 + +--- + +### 7. 任务列表(按依赖顺序) + +#### T01: 后端基础设施 + 数据库迁移 + +**描述**:新增 tags/transaction_tags 表、修改 health 端点位置、注册新路由模块、调整 express.json 限制。 + +**涉及文件**: +- `server/src/db/schema.sql` — 新增 tags、transaction_tags 表定义 +- `server/src/db/init.ts` — 新增迁移逻辑(创建 tags、transaction_tags 表) +- `server/src/index.ts` — health 移到 authMiddleware 前;注册 tag、export 路由;导入端点 body 限制调整 +- `server/src/routes/tag.ts` — 新建:标签 CRUD 端点 + 按标签统计端点 +- `server/src/routes/export.ts` — 新建:服务端流式导出端点 + +**依赖任务**:无 + +**优先级**:P0 + +--- + +#### T02: 后端 API 改造(现有路由扩展) + +**描述**:扩展现有路由以支持新功能——反馈 mine 端点、备份下载、交易 tagIds 支持、统计 period 参数。 + +**涉及文件**: +- `server/src/routes/feedback.ts` — 新增 `GET /mine` 端点 +- `server/src/routes/backup.ts` — 新增 `GET /:id/download` 端点(签名 URL 校验) +- `server/src/routes/transaction.ts` — POST/PUT 支持 tagIds;GET 支持 tagId 筛选;GET/:id 返回 tags +- `server/src/routes/stats.ts` — category/trend 增加 period 参数(week/year 视图) + +**依赖任务**:T01(tags 表需先存在) + +**优先级**:P0 + +--- + +#### T03: 前端 API 层 + Store 层 + 新页面 + +**描述**:新增所有前端 API 函数和 Store,创建新页面(标签管理、备份管理、数据导入),创建拖拽组件。 + +**涉及文件**: +- `client/src/api/feedback.ts` — 新增 `getMyFeedbacks()` +- `client/src/api/backup.ts` — 新建:备份列表 + 下载 API +- `client/src/api/tag.ts` — 新建:标签 CRUD + 统计 API +- `client/src/api/export.ts` — 新建:服务端导出 API +- `client/src/api/health.ts` — 新建:健康检查 API +- `client/src/api/stats.ts` — 修改:getCategoryStats/getTrend 支持 period +- `client/src/api/transaction.ts` — 新增 `importTransactions()`;类型扩展 tagIds/tagId +- `client/src/api/admin.ts` — 新增 `getHealth()` +- `client/src/stores/tag.ts` — 新建:标签 Pinia Store +- `client/src/stores/stats.ts` — 修改:fetchCategoryStats/fetchTrend 支持 period +- `client/src/components/DragSortList/DragSortList.vue` — 新建:拖拽排序组件 +- `client/src/pages/tag-manage/index.vue` — 新建:标签管理页面 +- `client/src/pages/backup-manage/index.vue` — 新建:备份管理页面 +- `client/src/pages/data-import/index.vue` — 新建:数据导入页面 +- `client/src/pages.json` — 注册新页面路由 + +**依赖任务**:T01(需知 API 端点格式) + +**优先级**:P1 + +--- + +#### T04: 前端现有页面改造 + +**描述**:改造现有页面以集成新功能——分类拖拽、反馈列表、统计维度切换、标签入口、服务端导出、健康检查卡片。 + +**涉及文件**: +- `client/src/pages/category-manage/index.vue` — 集成 DragSortList 拖拽排序 +- `client/src/pages/feedback/index.vue` — 新增"我的反馈"Tab +- `client/src/pages/stats/index.vue` — 添加维度切换器(周/月/年)+ 标签统计入口 +- `client/src/pages/profile/index.vue` — 导出面板增加"服务端导出"选项 +- `client/src/pages/add/index.vue` — 添加标签选择入口 +- `client/src/pages/bills/index.vue` — 添加标签筛选功能 +- `client/src/pages/admin/index.vue` — 添加服务状态卡片(30s 自动刷新) + +**依赖任务**:T03(依赖 API 层和组件) + +**优先级**:P1 + +--- + +#### T05: 集成联调 + 边界处理 + +**描述**:前后端联调、边界场景处理(导入去重、大量数据导出、拖拽边界)、最终测试修复。 + +**涉及文件**: +- 可能涉及 T01~T04 中所有文件的微调 +- 重点:`server/src/routes/export.ts`(大量数据流式导出稳定性) +- 重点:`server/src/routes/transaction.ts`(导入去重逻辑) +- 重点:`client/src/components/DragSortList/DragSortList.vue`(拖拽边界场景) + +**依赖任务**:T04 + +**优先级**:P2 + +--- + +### 8. 共享知识(跨文件约定) + +``` +- API 响应格式统一:{ code: 0, data: T } 成功;{ code: 5位数字, message: string } 失败 +- 错误码约定: + - 40001: 参数无效 + - 40002: 类型无效 + - 40100: 未登录 + - 40101: token过期 + - 40300: 无权限 + - 40400: 资源不存在 + - 42900: 请求频繁 + - 50000: 服务器内部错误 +- 金额单位统一为"分"(INT),前端展示时 / 100 +- 日期格式统一为 YYYY-MM-DD,月份为 YYYY-MM +- 认证使用 HMAC-SHA256 Token,Bearer 格式,30天有效期 +- 公开路径(无需认证):/api/auth/login, /api/auth/demo-login, /api/health, /api/backup/:id/download(token 校验) +- 前端 request() 函数自动处理 401 重登录、错误 toast +- Pinia Store 方法命名:fetch* (获取数据)、add/create (新增)、update (更新)、delete (删除) +- 前端 API 函数命名:get* (GET), create*/submit* (POST), update*/sort* (PUT), delete* (DELETE) +- SCSS 变量:$primary=#FF8C69, $success=#7BC67E, $danger=#FF6B6B, $text=#2D1B1B, $surface=#FFFFFF, $bg=#FFF8F0 +- 组件样式使用 scoped + @import '@/styles/mixins.scss' +- 标签预设 8 色:#FF8C69, #7BC67E, #5B9BD5, #FFD700, #FF69B4, #8B5CF6, #F97316, #06B6D4 +- 每笔交易最多 5 个标签,每用户最多 20 个标签 +- 导入文件限制 5MB,导出流式响应超时 60s +- 分页参数:page (从1开始), pageSize (默认20, 最大100) +``` + +--- + +### 9. 任务依赖图 + +```mermaid +graph LR + T01[T01: 后端基础设施+DB迁移] --> T02[T02: 后端API改造] + T01 --> T03[T03: 前端API+Store+新页面] + T02 --> T04[T04: 前端现有页面改造] + T03 --> T04 + T04 --> T05[T05: 集成联调+边界处理] +``` diff --git a/docs/class-diagram.mermaid b/docs/class-diagram.mermaid new file mode 100644 index 0000000..a345da9 --- /dev/null +++ b/docs/class-diagram.mermaid @@ -0,0 +1,55 @@ +classDiagram + class Tag { + +int id + +int user_id + +string name + +string color + +string created_at + } + + class TransactionTag { + +int transaction_id + +int tag_id + } + + class Transaction { + +int id + +int user_id + +int amount + +string type + +int category_id + +string note + +string date + +int group_id + +int recurring_id + +Tag[] tags + } + + class Feedback { + +int id + +int user_id + +string type + +string content + +string contact + +string status + +string admin_reply + +string created_at + } + + class Category { + +int id + +int user_id + +string name + +string icon + +string color + +string type + +int sort_order + +int is_custom + } + + Transaction "1" --o "*" TransactionTag : has + Tag "1" --o "*" TransactionTag : referenced_by + Transaction ..> Category : belongs_to + + note for Tag "每用户最多 20 个标签\n8 色预设颜色选择器\n标签不区分收支类型" + note for TransactionTag "复合主键 (transaction_id, tag_id)\n每笔交易最多 5 个标签" diff --git a/docs/prd-alignment.md b/docs/prd-alignment.md new file mode 100644 index 0000000..399cc7f --- /dev/null +++ b/docs/prd-alignment.md @@ -0,0 +1,254 @@ +# 小菜记账 — 前后端对齐增量 PRD + +> 版本: v1.0 | 日期: 2026-07-11 +> 类型: 增量 PRD(仅包含变更部分) +> 基线: 前后端对齐分析发现的 8 项差距 + +--- + +## 一、产品目标 + +| # | 目标 | 说明 | +|---|------|------| +| G1 | 消灭前后端差距,实现已开发后端能力的前端落地 | 后端已有排序、备份、健康检查等端点,但前端缺失对应 UI | +| G2 | 补齐用户反馈闭环,提升产品可信度 | 用户提交反馈后无法查看回复,体验断裂 | +| G3 | 扩展数据维度与吞吐能力 | 支持年度/周度统计、服务端导出、数据导入,为数据量增长做准备 | + +--- + +## 二、用户故事 + +### P0 — 分类拖拽排序 + +> As a 用户, I want 在分类管理页面拖拽调整分类顺序, so that 常用分类排在前面,记账时选得更快。 + +### P1 — 反馈回复展示 + +> As a 用户, I want 查看自己提交的反馈列表及管理员回复, so that 我知道反馈是否被处理以及处理结果。 + +### P1 — 备份管理页面 + +> As a 管理员, I want 在前端触发备份、查看备份列表并下载备份文件, so that 我可以在出现问题时快速恢复数据。 + +### P2 — 统计年度/周视图 + +> As a 用户, I want 在统计页面切换年/周/月维度, so that 我可以从不同时间粒度分析收支趋势。 + +### P2 — 数据导入与服务端导出 + +> As a 用户, I want 导入 JSON 格式的交易数据, so that 我可以从其他记账工具迁移历史数据。 +> +> As a 用户, I want 大数据量导出走服务端生成, so that 导出速度快、不卡顿。 + +### P3 — 健康检查 + +> As a 管理员, I want 在管理页面看到后端服务运行状态, so that 我能第一时间发现服务异常。 + +### P3 — 交易标签系统 + +> As a 用户, I want 给交易添加标签(如"出差""聚餐"), so that 我可以按标签筛选和统计关联交易。 +> +> As a 用户, I want 按标签筛选交易列表和查看标签维度的统计, so that 我可以追踪特定场景的收支。 + +--- + +## 三、需求池 + +### P0 — Must Have + +| ID | 需求 | 前端 | 后端 | 验收标准 | +|----|------|------|------|----------| +| P0-1 | 分类拖拽排序 | 分类管理页面添加拖拽排序 UI,拖拽结束后调用 `sortCategories(ids)` | 无新增(`PUT /categories/sort` 已实现) | 拖拽后顺序持久化;刷新页面顺序保持;支出/收入分类各自排序 | + +### P1 — Should Have + +| ID | 需求 | 前端 | 后端 | 验收标准 | +|----|------|------|------|----------| +| P1-1 | 用户反馈列表 | 反馈页面新增"我的反馈"Tab,展示反馈列表及管理员回复 | 新增 `GET /feedback/mine` 端点(用户维度,返回当前用户的反馈+`admin_reply`) | 用户只能看自己的反馈;未回复显示"待处理"状态;已回复显示回复内容和时间 | +| P1-2 | 备份管理页面 | 新增备份管理页面:触发备份按钮、备份列表、下载按钮 | 新增 `GET /backup/:id/download` 端点 | 管理员可触发备份并看到进度;列表显示备份时间和大小;点击下载获取备份文件 | + +### P2 — Nice to Have + +| ID | 需求 | 前端 | 后端 | 验收标准 | +|----|------|------|------|----------| +| P2-1 | 统计年度/周视图 | 统计页面添加维度切换器(周/月/年),切换后图表和排行联动更新 | `GET /stats/category` 和 `GET /stats/trend` 支持 `period=week|month|year` 参数,按对应维度聚合数据 | 周视图显示本周每日数据;年视图显示每月汇总;切换时图表平滑过渡 | +| P2-2 | 数据导入 | 我的页面新增"数据导入"入口;支持 JSON 文件上传;导入前预览数据条数,确认后提交 | 新增 `POST /api/transactions/import` 端点,接收 JSON 文件,校验格式,批量写入 | 支持 JSON 格式;导入前展示条数预览;重复数据(同日期/金额/分类)跳过并提示;导入完成显示成功/跳过计数 | +| P2-3 | 数据导出服务端化 | 数据导出页面新增"服务端导出"选项;大数据量(>1000条)自动走服务端 | 新增 `GET /api/export` 端点,支持 CSV/JSON 格式,流式响应 | 客户端导出保留作为轻量选项;服务端导出无条数限制;下载进度可感知 | + +### P3 — Nice to Have(远期) + +| ID | 需求 | 前端 | 后端 | 验收标准 | +|----|------|------|------|----------| +| P3-1 | 健康检查展示 | 管理页面新增服务状态卡片,显示后端健康信息 | 将 `/api/health` 端点移到 authMiddleware 之前 | 管理页面实时显示服务状态(正常/异常);无需登录即可调用健康检查 | +| P3-2 | 交易标签 — 标签管理 | 新增标签管理页面:创建/编辑/删除标签(名称+颜色) | 新增 `tags` 表及 CRUD 端点(`GET/POST/PUT/DELETE /api/tags`) | 每个用户独立标签;标签不可重名;删除标签时解绑关联交易 | +| P3-3 | 交易标签 — 交易关联 | 记账页面新增"添加标签"入口;交易详情/编辑页可修改标签 | `transactions` 表新增关联;`POST/PUT /transactions` 支持 `tagIds` 字段 | 一笔交易可关联多个标签;标签选择器展示用户已有标签 | +| P3-4 | 交易标签 — 标签筛选统计 | 账单页支持按标签筛选;统计页新增标签维度视图 | `GET /transactions` 支持 `tagId` 筛选参数;`GET /stats/by-tag` 新端点 | 筛选结果仅显示含指定标签的交易;标签统计展示各标签下收支总额 | + +--- + +## 四、UI 交互说明 + +### P0-1 分类拖拽排序 + +``` +分类管理页面(category-manage/index.vue)变更: + +1. 页面顶部新增提示栏:"长按拖拽可调整分类顺序" +2. 每个分类项左侧显示拖拽手柄图标(Lucide GripVertical, #BFB3B3) +3. 长按分类项(300ms)进入拖拽模式: + - 被拖拽项 elevation 提升(shadow-clay-float) + - 背景色变为 #FFF0E6 + - 其余项轻微缩进让出空间 +4. 拖拽过程中实时交换位置(带 150ms 过渡动画) +5. 松手后: + - 调用 sortCategories(ids) 提交新顺序 + - 成功:轻量 toast "排序已保存" + - 失败:回滚到原位置 + 错误提示 +6. 支出/收入 Tab 各自独立排序,互不影响 +``` + +### P1-1 用户反馈列表 + +``` +反馈页面(feedback/index.vue)变更: + +1. 页面顶部新增 Tab 切换:"提交反馈" | "我的反馈" +2. "我的反馈" Tab 内容: + ├── 列表按提交时间倒序排列 + ├── 每项显示: + │ ├── 反馈内容(截断2行,点击展开) + │ ├── 提交时间(相对时间:"3天前") + │ ├── 状态标签: + │ │ ├── 待处理 → 灰色 #BFB3B3 + │ │ ├── 已回复 → 绿色 #7BC67E + │ │ └── 已关闭 → 橙色 #FF8C69 + │ └── 管理员回复区域(仅已回复时显示): + │ ├── 分割线 + "管理员回复" 标签 + │ ├── 回复内容 + │ └── 回复时间 + └── 空状态:插画 + "还没有提交过反馈" +3. 下拉刷新更新列表 +4. 上拉加载更多(分页) +``` + +### P1-2 备份管理页面 + +``` +新增页面(backup-manage/index.vue): + +1. 顶部操作区: + ├── [立即备份] 主按钮(Clay 风格) + ├── 点击后按钮变为 loading 态 + └── 成功后列表顶部新增一条记录 + toast "备份成功" +2. 备份列表: + ├── 按时间倒序 + ├── 每项显示: + │ ├── 备份时间(2026-07-11 14:30) + │ ├── 文件大小(12.3 MB) + │ └── [下载] 按钮(Lucide Download 图标) + ├── 点击下载: + │ ├── 按钮变为进度条 + │ └── 完成后 toast "下载完成" + └── 空状态:"暂无备份记录" +3. 仅管理员角色可见此页面入口 +``` + +### P2-1 统计年度/周视图 + +``` +统计页面(stats/index.vue)变更: + +1. 月份选择器升级为维度切换器: + ├── 新增三个 Tab:周 | 月 | 年 + ├── 样式同现有支出/收入切换器 + └── 切换后日期选择器联动: + ├── 周:显示 "2026年7月 第2周",左右切换周 + ├── 月:保持现有行为 + └── 年:显示 "2026年",左右切换年 +2. 图表联动更新: + ├── 周维度:趋势图 X 轴为周一~周日 + ├── 年维度:趋势图 X 轴为1月~12月 + └── 环形图和排行同步更新为对应维度数据 +3. 概览卡片数值联动 +``` + +### P2-2 & P2-3 数据导入与导出 + +``` +我的页面变更 + 新增页面: + +1. "数据导出"入口变更: + ├── 点击后进入导出页面 + ├── 导出方式选择: + │ ├── 客户端导出(< 1000 条推荐)— 现有逻辑 + │ └── 服务端导出(≥ 1000 条推荐)— 新增 + ├── 格式选择:CSV | JSON + ├── 日期范围选择器 + └── [开始导出] 按钮 +2. 新增"数据导入"入口(我的页面功能列表): + ├── 点击后进入导入页面 + ├── 步骤流程: + │ ├── Step 1:选择文件(仅 .json) + │ ├── Step 2:预览 — 显示识别条数、日期范围、分类映射 + │ ├── Step 3:确认导入 + │ └── 结果:成功 N 条 / 跳过 M 条 + └── 导入模板下载链接 +``` + +### P3-1 健康检查展示 + +``` +管理页面变更: + +1. 页面顶部新增服务状态卡片: + ├── 正常:绿色圆点 + "服务运行正常" + 响应时间 "23ms" + ├── 异常:红色圆点 + "服务异常" + 错误信息 + └── 检测中:灰色圆点 + 旋转图标 +2. 每 30 秒自动刷新一次 +3. 点击卡片可手动刷新 +``` + +### P3-2~4 交易标签系统 + +``` +全局变更: + +1. 记账页面(add/index.vue): + ├── 附加信息区新增"添加标签"行 + ├── 点击弹出标签选择器(底部弹窗): + │ ├── 已有标签列表(可多选) + │ ├── 搜索框 + │ └── [新建标签] 快捷入口 + └── 已选标签以药丸标签形式展示在输入区 +2. 账单页面(bills/index.vue): + ├── 筛选栏新增标签筛选 + └── 筛选弹窗中增加标签选择 +3. 统计页面(stats/index.vue): + └── 新增"标签统计"卡片(环形图 + 排行) +4. 我的页面: + └── 功能列表新增"标签管理"入口 +5. 标签管理页面(tag-manage/index.vue)— 新增: + ├── 标签列表(名称 + 颜色色块 + 关联交易数) + ├── [新建标签] 按钮 + ├── 编辑弹窗:名称输入 + 颜色选择器(8色预设) + └── 删除确认:关联 N 笔交易时提示"将解除关联" +``` + +--- + +## 五、待确认问题 + +| # | 问题 | 影响范围 | 建议 | +|---|------|----------|------| +| Q1 | 分类拖拽排序是否需要动画反馈?拖拽手柄位置(左侧还是右侧)? | P0-1 | 建议左侧手柄 + 长按触发,符合主流交互习惯 | +| Q2 | 用户反馈列表是否需要通知推送?当管理员回复后用户是否收到微信服务通知? | P1-1 | 后端已有通知逻辑,但微信订阅消息需用户主动订阅,建议先做页面内展示,通知作为后续增强 | +| Q3 | 备份文件的下载鉴权方式?URL 是否需要时效性签名? | P1-2 | 建议使用临时签名 URL(有效期 1 小时),避免备份文件被猜测 URL 直接访问 | +| Q4 | 统计年度视图的趋势图粒度:按月汇总还是按季汇总? | P2-1 | 建议按月汇总(12 个点),季度可通过后续分组实现 | +| Q5 | 数据导入的 JSON 格式规范?是否需要支持其他记账 App 的导出格式? | P2-2 | 建议先定义小菜记账自有 JSON 格式,提供模板下载;其他 App 格式兼容作为远期需求 | +| Q6 | 服务端导出的文件存储方式:内存生成直接流式返回 vs 先写文件再下载? | P2-3 | 建议 < 1 万条内存流式返回,≥ 1 万条先写临时文件再下载,避免内存溢出 | +| Q7 | 交易标签上限:每笔交易最多关联几个标签?每个用户最多创建多少个标签? | P3-2~4 | 建议每笔交易最多 5 个标签,每用户最多 20 个标签,后续可调整 | +| Q8 | 标签与分类的关系:标签是否可以跨分类?同一标签能否同时用于支出和收入? | P3-2~4 | 建议标签不区分收支类型,跨分类使用,这是标签区别于分类的核心价值 | + +--- + +*文档版本: v1.0 | 最后更新: 2026-07-11* diff --git a/docs/sequence-diagram.mermaid b/docs/sequence-diagram.mermaid new file mode 100644 index 0000000..7ee953a --- /dev/null +++ b/docs/sequence-diagram.mermaid @@ -0,0 +1,53 @@ +sequenceDiagram + participant U as 用户 + participant P as category-manage + participant D as DragSortList + participant S as categoryStore + participant A as sortCategories API + participant B as PUT /categories/sort + + U->>P: 长按分类项进入排序模式 + P->>P: showSortMode = true + P->>D: 渲染 DragSortList (items=currentCategories) + U->>D: touchstart (记录起始位置) + U->>D: touchmove (计算偏移, 交换元素位置) + D->>D: 实时更新 items 数组顺序 + U->>D: touchend (拖拽结束) + D->>P: @change事件 (新顺序ids) + P->>S: sortCategories(newIds) + S->>A: sortCategories(ids) + A->>B: PUT /categories/sort { ids } + B-->>A: { code: 0 } + A-->>S: 成功 + S->>S: fetchCategories() 刷新 + + %% 数据导入流程 + + U->>P2 as data-import: 选择 JSON 文件 + P2->>P2: uni.chooseFile / 读取文件内容 + P2->>P2: 解析 JSON,显示预览(条数、日期范围) + U->>P2: 确认导入 + P2->>A2 as transaction API: importTransactions(items) + A2->>S2 as POST /transactions/import: POST { items } + S2->>S2: 校验每条记录格式 + S2->>DB as MySQL: 查询已有记录 (去重比对) + S2->>DB: 批量 INSERT (每批100条) + S2->>DB: 写入 transaction_tags (如有 tag_names) + S2-->>A2: { total, imported, skipped, errors } + A2-->>P2: 导入结果 + P2->>U: 显示导入结果(成功X条, 跳过Y条) + + %% 标签关联交易流程 + + U->>P3 as add/index: 点击"添加标签" + P3->>P3: 显示标签选择面板(已有标签 + 新建入口) + U->>P3: 选择标签(最多5个) + P3->>P3: selectedTagIds 更新 + U->>P3: 保存交易 + P3->>A3 as transaction API: createTransaction({...data, tagIds}) + A3->>S3 as POST /transactions: POST { amount, type, ..., tagIds } + S3->>S3: 校验 tagIds (≤5, 属于当前用户) + S3->>DB: INSERT INTO transactions + S3->>DB: INSERT INTO transaction_tags (批量) + S3-->>A3: { id } + A3-->>P3: 成功 diff --git a/server/src/db/init.ts b/server/src/db/init.ts index 17e4681..8faf7ff 100644 --- a/server/src/db/init.ts +++ b/server/src/db/init.ts @@ -277,6 +277,40 @@ async function runMigrations(conn: mysql.Connection) { console.log('[DB] Migrating: adding recurring_id to transactions') await conn.query("ALTER TABLE transactions ADD COLUMN recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID' AFTER group_id") } + + // 标签表 + const hasTags = await tableExists(conn, 'tags') + if (!hasTags) { + console.log('[DB] Migrating: creating tags table') + await conn.query(` + CREATE TABLE IF NOT EXISTS tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(20) NOT NULL COMMENT '标签名称', + color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_name (user_id, name), + INDEX idx_user (user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) + } + + // 交易-标签关联表 + const hasTransactionTags = await tableExists(conn, 'transaction_tags') + if (!hasTransactionTags) { + console.log('[DB] Migrating: creating transaction_tags table') + await conn.query(` + CREATE TABLE IF NOT EXISTS transaction_tags ( + transaction_id INT NOT NULL, + tag_id INT NOT NULL, + PRIMARY KEY (transaction_id, tag_id), + INDEX idx_tag (tag_id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) + } } export async function initDatabase() { diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index af39cf7..ab20f90 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -170,3 +170,23 @@ CREATE TABLE IF NOT EXISTS recurring_templates ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(20) NOT NULL COMMENT '标签名称', + color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_name (user_id, name), + INDEX idx_user (user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS transaction_tags ( + transaction_id INT NOT NULL, + tag_id INT NOT NULL, + PRIMARY KEY (transaction_id, tag_id), + INDEX idx_tag (tag_id), + FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/src/index.ts b/server/src/index.ts index 7db4793..00f7997 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -24,6 +24,8 @@ import configRoutes from './routes/config' import trackRoutes from './routes/track' import logsRoutes from './routes/logs' import recurringRoutes from './routes/recurring' +import tagRoutes from './routes/tag' +import exportRoutes from './routes/export' import { backupDatabase } from './utils/backup' // Warn if token secret is using default fallback @@ -50,7 +52,7 @@ app.use(cors({ }, credentials: true, })) -app.use(express.json({ limit: '10kb' })) +app.use(express.json({ limit: '5mb' })) // 请求日志 app.use(requestLogger) @@ -145,6 +147,28 @@ app.get('/api/notifications/image/:filename', async (req, res) => { } }) +// 健康检查端点(公开,不经过 auth 中间件) +app.get('/api/health', async (_req, res) => { + try { + await pool.query('SELECT 1') + res.json({ + status: 'ok', + db: 'connected', + uptime: Math.floor(process.uptime()), + version: process.env.npm_package_version || '1.0.0', + timestamp: new Date().toISOString() + }) + } catch { + res.status(503).json({ + status: 'error', + db: 'disconnected', + uptime: Math.floor(process.uptime()), + version: process.env.npm_package_version || '1.0.0', + timestamp: new Date().toISOString() + }) + } +}) + app.use(authMiddleware) app.use('/api/auth', authLimiter, authRoutes) @@ -163,15 +187,8 @@ app.use('/api/config', apiLimiter, configRoutes) app.use('/api/track', apiLimiter, trackRoutes) app.use('/api/logs', apiLimiter, logsRoutes) app.use('/api/recurring', apiLimiter, recurringRoutes) - -app.get('/api/health', async (_req, res) => { - try { - await pool.query('SELECT 1') - res.json({ status: 'ok', db: 'connected', time: new Date().toISOString() }) - } catch { - res.status(503).json({ status: 'error', db: 'disconnected', time: new Date().toISOString() }) - } -}) +app.use('/api/tags', apiLimiter, tagRoutes) +app.use('/api/export', apiLimiter, exportRoutes) // 全局错误处理中间件(兜底未捕获的异常) app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts index f877e3b..98f3722 100644 --- a/server/src/routes/backup.ts +++ b/server/src/routes/backup.ts @@ -2,11 +2,74 @@ import { Router, Response } from 'express' import { AuthRequest } from '../middleware/auth' import { backupDatabase, getBackupList } from '../utils/backup' import { requireAdmin } from '../middleware/requireAdmin' +import { createHmac } from 'crypto' +import fs from 'fs' +import path from 'path' const router = Router() +const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai' +const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret' -// 所有路由都需要管理员权限 -router.use((req, res, next) => requireAdmin(req, res, next)) +// 所有路由都需要管理员权限(除了 :id/download 使用签名校验) +router.use((req, res, next) => { + // /:id/download 不走 requireAdmin,自行校验签名 + if (req.path.match(/^\/\d+\/download$/)) { + return next() + } + requireAdmin(req, res, next) +}) + +// 下载备份文件(签名 token 校验,不走 authMiddleware) +router.get('/:id/download', async (req: AuthRequest, res: Response) => { + try { + const token = req.query.token as string + if (!token) { + return res.status(400).json({ code: 40001, message: '缺少下载令牌' }) + } + + // 解析 token:backupId:timestamp:hmac + const parts = token.split(':') + if (parts.length !== 3) { + return res.status(400).json({ code: 40001, message: '令牌格式无效' }) + } + + const [backupId, timestamp, hmac] = parts + + // 校验 timestamp 未过期(1小时内) + const ts = parseInt(timestamp) + if (isNaN(ts) || Date.now() - ts > 3600000) { + return res.status(400).json({ code: 40001, message: '令牌已过期' }) + } + + // 校验 HMAC + const expectedHmac = createHmac('sha256', JWT_SECRET) + .update(`${backupId}:${timestamp}`) + .digest('hex') + if (hmac !== expectedHmac) { + return res.status(403).json({ code: 40300, message: '令牌签名无效' }) + } + + // 校验 backupId 存在于备份列表中 + const list = getBackupList() + const backup = list.find(b => b.name === backupId) + if (!backup) { + return res.status(404).json({ code: 40400, message: '备份不存在' }) + } + + // 流式返回备份文件 + const filepath = path.join(BACKUP_DIR, backup.name) + if (!fs.existsSync(filepath)) { + return res.status(404).json({ code: 40400, message: '备份文件不存在' }) + } + + res.setHeader('Content-Type', 'application/gzip') + res.setHeader('Content-Disposition', `attachment; filename=${backup.name}`) + fs.createReadStream(filepath).pipe(res) + } catch (err) { + console.error('[Backup] download error:', err) + res.status(500).json({ code: 50000, message: '下载失败' }) + } +}) // 手动触发备份 router.post('/', async (req: AuthRequest, res: Response) => { @@ -23,11 +86,45 @@ router.post('/', async (req: AuthRequest, res: Response) => { router.get('/', async (req: AuthRequest, res: Response) => { try { const list = getBackupList() - res.json({ code: 0, data: list }) + // 为每条记录生成下载令牌 + const listWithToken = list.map(item => { + const timestamp = Date.now() + const hmac = createHmac('sha256', JWT_SECRET) + .update(`${item.name}:${timestamp}`) + .digest('hex') + return { + ...item, + downloadToken: `${item.name}:${timestamp}:${hmac}` + } + }) + res.json({ code: 0, data: listWithToken }) } catch (err) { console.error('[Backup] 获取列表失败:', err) res.status(500).json({ code: 50000, message: '获取备份列表失败' }) } }) +// 删除备份 +router.delete('/:id', async (req: AuthRequest, res: Response) => { + try { + const backupName = req.params.id + const list = getBackupList() + const backup = list.find(b => b.name === backupName) + if (!backup) { + return res.status(404).json({ code: 40400, message: '备份不存在' }) + } + + // 删除备份文件 + const filepath = path.join(BACKUP_DIR, backup.name) + if (fs.existsSync(filepath)) { + fs.unlinkSync(filepath) + } + + res.json({ code: 0 }) + } catch (err) { + console.error('[Backup] 删除备份失败:', err) + res.status(500).json({ code: 50000, message: '删除备份失败' }) + } +}) + export default router diff --git a/server/src/routes/export.ts b/server/src/routes/export.ts new file mode 100644 index 0000000..69ddac7 --- /dev/null +++ b/server/src/routes/export.ts @@ -0,0 +1,231 @@ +import { Router, Response } from 'express' +import pool from '../db/connection' +import { AuthRequest } from '../middleware/auth' +import { Readable } from 'stream' +import { pipeline } from 'stream/promises' +import fs from 'fs' +import os from 'os' +import path from 'path' + +const router = Router() + +const VALID_TYPES = ['expense', 'income', 'all'] +const VALID_FORMATS = ['csv', 'json'] +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/ +const LARGE_DATA_THRESHOLD = 10000 +const EXPORT_TIMEOUT = 60000 + +/** 流式导出交易数据 */ +router.get('/', async (req: AuthRequest, res: Response) => { + try { + // 支持 query token 认证:如果没有 Authorization header,从 query.token 读取 + if (!req.headers.authorization && req.query.token) { + req.headers.authorization = `Bearer ${req.query.token}` + } + + const { startDate, endDate, type = 'all', format = 'csv' } = req.query + + // 参数校验 + if (!startDate || !endDate || !DATE_REGEX.test(startDate as string) || !DATE_REGEX.test(endDate as string)) { + return res.status(400).json({ code: 40001, message: '日期参数无效' }) + } + if (!VALID_TYPES.includes(type as string)) { + return res.status(400).json({ code: 40001, message: '类型参数无效' }) + } + if (!VALID_FORMATS.includes(format as string)) { + return res.status(400).json({ code: 40001, message: '格式参数无效' }) + } + + const isCsv = format === 'csv' + const today = new Date().toISOString().slice(0, 10).replace(/-/g, '') + const filename = isCsv ? `export_${today}.csv` : `export_${today}.json` + + // 构建查询条件 + let where = 'WHERE t.user_id = ? AND t.date >= ? AND t.date <= ?' + const params: any[] = [req.userId, startDate, endDate] + + if (type !== 'all') { + where += ' AND t.type = ?' + params.push(type) + } + + // 先统计数量 + const [countResult] = await pool.query( + `SELECT COUNT(*) as total FROM transactions t ${where}`, + params + ) + const total = (countResult as any[])[0].total + + if (total === 0) { + // 空数据直接返回 + if (isCsv) { + res.setHeader('Content-Type', 'text/csv; charset=utf-8') + res.setHeader('Content-Disposition', `attachment; filename=${filename}`) + res.send('\uFEFF日期,类型,分类,金额,备注\n') + } else { + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.setHeader('Content-Disposition', `attachment; filename=${filename}`) + res.send('[]') + } + return + } + + // 设置响应头 + if (isCsv) { + res.setHeader('Content-Type', 'text/csv; charset=utf-8') + } else { + res.setHeader('Content-Type', 'application/json; charset=utf-8') + } + res.setHeader('Content-Disposition', `attachment; filename=${filename}`) + + // 超时保护 + const timeout = setTimeout(() => { + if (!res.writableEnded) { + res.end() + } + }, EXPORT_TIMEOUT) + + if (total < LARGE_DATA_THRESHOLD) { + // 小数据量:内存流 + const [rows] = await pool.query( + `SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.type, + COALESCE(c.name, '未分类') as category_name, + t.amount, COALESCE(t.note, '') as note + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + ${where} + ORDER BY t.date ASC, t.created_at ASC`, + params + ) + + const data = rows as any[] + const readable = new Readable({ + read() {} + }) + + if (isCsv) { + readable.push('\uFEFF日期,类型,分类,金额,备注\n') + for (const row of data) { + const note = String(row.note).replace(/"/g, '""') + const typeLabel = row.type === 'expense' ? '支出' : '收入' + readable.push(`${row.date},${typeLabel},${row.category_name},${row.amount},"${note}"\n`) + } + } else { + readable.push('[') + for (let i = 0; i < data.length; i++) { + const row = data[i] + const entry = JSON.stringify({ + date: row.date, + type: row.type, + category: row.category_name, + amount: row.amount, + note: row.note + }) + readable.push(i === 0 ? entry : ',' + entry) + } + readable.push(']') + } + readable.push(null) + + readable.on('end', () => { + clearTimeout(timeout) + }) + readable.on('error', () => { + clearTimeout(timeout) + }) + readable.on('close', () => { + clearTimeout(timeout) + }) + + readable.pipe(res) + } else { + // 大数据量:临时文件流 + const tmpDir = os.tmpdir() + const tmpFile = path.join(tmpDir, `export_${Date.now()}_${req.userId}.${format}`) + + // 分批查询并写入临时文件 + const BATCH_SIZE = 5000 + let offset = 0 + const writeStream = fs.createWriteStream(tmpFile, { encoding: 'utf-8' }) + + if (isCsv) { + writeStream.write('\uFEFF日期,类型,分类,金额,备注\n') + } else { + writeStream.write('[') + } + + let isFirst = true + while (offset < total) { + const [rows] = await pool.query( + `SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.type, + COALESCE(c.name, '未分类') as category_name, + t.amount, COALESCE(t.note, '') as note + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + ${where} + ORDER BY t.date ASC, t.created_at ASC + LIMIT ? OFFSET ?`, + [...params, BATCH_SIZE, offset] + ) + + const data = rows as any[] + for (const row of data) { + if (isCsv) { + const note = String(row.note).replace(/"/g, '""') + const typeLabel = row.type === 'expense' ? '支出' : '收入' + writeStream.write(`${row.date},${typeLabel},${row.category_name},${row.amount},"${note}"\n`) + } else { + const entry = JSON.stringify({ + date: row.date, + type: row.type, + category: row.category_name, + amount: row.amount, + note: row.note + }) + writeStream.write(isFirst ? entry : ',' + entry) + isFirst = false + } + } + + offset += BATCH_SIZE + } + + if (!isCsv) { + writeStream.write(']') + } + writeStream.end() + + // 等待写入完成 + await new Promise((resolve, reject) => { + writeStream.on('finish', resolve) + writeStream.on('error', reject) + }) + + // 流式返回临时文件 + const readStream = fs.createReadStream(tmpFile, { encoding: 'utf-8' }) + + const cleanup = () => { + clearTimeout(timeout) + // 删除临时文件 + fs.unlink(tmpFile, (err) => { + if (err) console.error('[Export] 删除临时文件失败:', err) + }) + } + + readStream.on('end', cleanup) + readStream.on('error', cleanup) + readStream.on('close', cleanup) + + await pipeline(readStream, res) + } + } catch (err) { + console.error('[Export] GET error:', err) + if (!res.headersSent) { + res.status(500).json({ code: 50000, message: '服务器错误' }) + } else { + res.end() + } + } +}) + +export default router diff --git a/server/src/routes/feedback.ts b/server/src/routes/feedback.ts index 5a7bb79..0a8bba4 100644 --- a/server/src/routes/feedback.ts +++ b/server/src/routes/feedback.ts @@ -5,6 +5,36 @@ import { requireAdmin } from '../middleware/requireAdmin' const router = Router() +/** 当前用户的反馈列表 */ +router.get('/mine', authMiddleware, async (req, res) => { + try { + const userId = (req as any).userId + 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 offset = (page - 1) * pageSize + + const [countResult] = await pool.execute( + 'SELECT COUNT(*) as total FROM feedbacks WHERE user_id = ?', + [userId] + ) + const total = (countResult as any[])[0].total + + const [rows] = await pool.execute( + `SELECT id, type, content, contact, status, admin_reply, created_at + FROM feedbacks + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ? OFFSET ?`, + [userId, pageSize, offset] + ) + + res.json({ code: 0, data: { list: rows, total, page, pageSize } }) + } catch (error) { + console.error('Get my feedback error:', error) + res.status(500).json({ code: 50000, message: '获取失败' }) + } +}) + /** 用户提交反馈 */ router.post('/', authMiddleware, async (req, res) => { try { @@ -52,15 +82,15 @@ router.get('/', requireAdmin, async (req, res) => { ) const total = (countResult as any[])[0].total - // LIMIT/OFFSET 直接嵌入 SQL(execute 对参数类型要求严格) + // LIMIT/OFFSET 使用参数化查询 const [rows] = await pool.execute( `SELECT f.*, u.nickname, u.avatar_url FROM feedbacks f LEFT JOIN users u ON f.user_id = u.id WHERE ${where} ORDER BY f.created_at DESC - LIMIT ${pageSize} OFFSET ${offset}`, - params + LIMIT ? OFFSET ?`, + [...params, pageSize, offset] ) res.json({ code: 0, data: { list: rows, total, page, pageSize } }) diff --git a/server/src/routes/stats.ts b/server/src/routes/stats.ts index b2b4308..d68a24f 100644 --- a/server/src/routes/stats.ts +++ b/server/src/routes/stats.ts @@ -5,6 +5,7 @@ import { getCurrentMonth, getMonthRange } from '../utils/date' const router = Router() const VALID_TYPES = ['expense', 'income'] +const VALID_PERIODS = ['week', 'month', 'year'] /** 根据 group_id 参数构建 WHERE 条件(含成员验证) */ async function buildWhereClause( @@ -103,14 +104,40 @@ router.get('/overview', async (req: AuthRequest, res: Response) => { router.get('/category', async (req: AuthRequest, res: Response) => { try { - const { month, type = 'expense', group_id } = req.query + const { month, type = 'expense', group_id, period = 'month' } = req.query if (!VALID_TYPES.includes(type as string)) { return res.status(400).json({ code: 40002, message: '类型无效' }) } + if (!VALID_PERIODS.includes(period as string)) { + return res.status(400).json({ code: 40001, message: 'period参数无效,仅支持week/month/year' }) + } + + let startDate: string + let endDate: string + + const now = new Date() const m = (month as string) || getCurrentMonth() - const range = getMonthRange(m) - if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' }) - const { startDate, endDate } = range + + if (period === 'week') { + // 本周一到本周日 + const dayOfWeek = now.getDay() || 7 + const monday = new Date(now) + monday.setDate(now.getDate() - dayOfWeek + 1) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + startDate = monday.toISOString().slice(0, 10) + endDate = sunday.toISOString().slice(0, 10) + } else if (period === 'year') { + const year = m.split('-')[0] || String(now.getFullYear()) + startDate = `${year}-01-01` + endDate = `${year}-12-31` + } else { + // month(默认) + const range = getMonthRange(m) + if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' }) + startDate = range.startDate + endDate = range.endDate + } const result = await buildWhereClause( req.userId, @@ -138,14 +165,57 @@ router.get('/category', async (req: AuthRequest, res: Response) => { router.get('/trend', async (req: AuthRequest, res: Response) => { try { - const { month, type = 'expense', group_id } = req.query + const { month, type = 'expense', group_id, period = 'month' } = req.query if (!VALID_TYPES.includes(type as string)) { return res.status(400).json({ code: 40002, message: '类型无效' }) } + if (!VALID_PERIODS.includes(period as string)) { + return res.status(400).json({ code: 40001, message: 'period参数无效,仅支持week/month/year' }) + } + + let startDate: string + let endDate: string + let groupBy: string + let labelExpr: string + + const now = new Date() const m = (month as string) || getCurrentMonth() - const range = getMonthRange(m) - if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' }) - const { startDate, endDate } = range + + if (period === 'week') { + // 本周一到本周日 + const dayOfWeek = now.getDay() || 7 + const monday = new Date(now) + monday.setDate(now.getDate() - dayOfWeek + 1) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + startDate = monday.toISOString().slice(0, 10) + endDate = sunday.toISOString().slice(0, 10) + groupBy = 't.date' + labelExpr = `CASE DAYOFWEEK(t.date) + WHEN 1 THEN '周日' + WHEN 2 THEN '周一' + WHEN 3 THEN '周二' + WHEN 4 THEN '周三' + WHEN 5 THEN '周四' + WHEN 6 THEN '周五' + WHEN 7 THEN '周六' + END` + } else if (period === 'year') { + // 当年1月1日到12月31日 + const year = m.split('-')[0] || String(now.getFullYear()) + startDate = `${year}-01-01` + endDate = `${year}-12-31` + groupBy = "DATE_FORMAT(t.date, '%Y-%m')" + labelExpr = `CONCAT(MONTH(t.date), '月')` + } else { + // month(默认) + const range = getMonthRange(m) + if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' }) + startDate = range.startDate + endDate = range.endDate + groupBy = 't.date' + labelExpr = `CONCAT(DAY(t.date), '日')` + } const result = await buildWhereClause( req.userId, @@ -155,10 +225,10 @@ router.get('/trend', async (req: AuthRequest, res: Response) => { if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' }) const [rows] = await pool.query( - `SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, SUM(t.amount) as amount + `SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, ${labelExpr} as label, SUM(t.amount) as amount FROM transactions t ${result.where} - GROUP BY t.date + GROUP BY ${groupBy} ORDER BY t.date`, [...result.params, type, startDate, endDate] ) diff --git a/server/src/routes/tag.ts b/server/src/routes/tag.ts new file mode 100644 index 0000000..bcd584d --- /dev/null +++ b/server/src/routes/tag.ts @@ -0,0 +1,216 @@ +import { Router, Response } from 'express' +import pool from '../db/connection' +import { AuthRequest } from '../middleware/auth' +import { getCurrentMonth, getMonthRange } from '../utils/date' + +const router = Router() + +const VALID_COLORS = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4'] +const MAX_TAGS_PER_USER = 20 +const MAX_TAG_NAME_LENGTH = 20 + +/** 获取当前用户所有标签 */ +router.get('/', async (req: AuthRequest, res: Response) => { + try { + const [rows] = await pool.query( + 'SELECT id, user_id, name, color, created_at FROM tags WHERE user_id = ? ORDER BY created_at ASC', + [req.userId] + ) + res.json({ code: 0, data: { list: rows } }) + } catch (err) { + console.error('[Tag] GET error:', err) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + +/** 创建标签 */ +router.post('/', async (req: AuthRequest, res: Response) => { + try { + const { name, color } = req.body + + if (!name || typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ code: 40001, message: '标签名称不能为空' }) + } + + const trimmedName = name.trim() + if (trimmedName.length > MAX_TAG_NAME_LENGTH) { + return res.status(400).json({ code: 40001, message: '标签名称不能超过20个字符' }) + } + + if (!color || typeof color !== 'string' || !VALID_COLORS.includes(color)) { + return res.status(400).json({ code: 40001, message: '标签颜色无效' }) + } + + // 检查用户标签数量限制 + const [countResult] = await pool.query( + 'SELECT COUNT(*) as cnt FROM tags WHERE user_id = ?', + [req.userId] + ) + if ((countResult as any[])[0].cnt >= MAX_TAGS_PER_USER) { + return res.status(400).json({ code: 40001, message: '标签数量已达上限(20个)' }) + } + + // 检查同名标签 + const [existResult] = await pool.query( + 'SELECT id FROM tags WHERE user_id = ? AND name = ?', + [req.userId, trimmedName] + ) + if ((existResult as any[]).length > 0) { + return res.status(400).json({ code: 40001, message: '标签名称已存在' }) + } + + const [result] = await pool.query( + 'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)', + [req.userId, trimmedName, color] + ) + + res.json({ code: 0, data: { id: (result as any).insertId } }) + } catch (err) { + console.error('[Tag] POST error:', err) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + +/** 更新标签 */ +router.put('/:id', async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params + const { name, color } = req.body + + // 校验标签属于当前用户 + const [existRows] = await pool.query( + 'SELECT id FROM tags WHERE id = ? AND user_id = ?', + [id, req.userId] + ) + if ((existRows as any[]).length === 0) { + return res.status(404).json({ code: 40400, message: '标签不存在' }) + } + + const updates: string[] = [] + const params: any[] = [] + + if (name !== undefined) { + if (typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ code: 40001, message: '标签名称不能为空' }) + } + const trimmedName = name.trim() + if (trimmedName.length > MAX_TAG_NAME_LENGTH) { + return res.status(400).json({ code: 40001, message: '标签名称不能超过20个字符' }) + } + // 检查同名标签(排除自身) + const [dupRows] = await pool.query( + 'SELECT id FROM tags WHERE user_id = ? AND name = ? AND id != ?', + [req.userId, trimmedName, id] + ) + if ((dupRows as any[]).length > 0) { + return res.status(400).json({ code: 40001, message: '标签名称已存在' }) + } + updates.push('name = ?') + params.push(trimmedName) + } + + if (color !== undefined) { + if (!VALID_COLORS.includes(color)) { + return res.status(400).json({ code: 40001, message: '标签颜色无效' }) + } + updates.push('color = ?') + params.push(color) + } + + if (updates.length === 0) { + return res.status(400).json({ code: 40001, message: '缺少更新内容' }) + } + + params.push(id) + await pool.query( + `UPDATE tags SET ${updates.join(', ')} WHERE id = ?`, + params + ) + + res.json({ code: 0 }) + } catch (err) { + console.error('[Tag] PUT error:', err) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + +/** 删除标签 */ +router.delete('/:id', async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params + + // 校验标签属于当前用户(级联删除 transaction_tags 由外键处理) + const [result] = await pool.query( + 'DELETE FROM tags WHERE id = ? AND user_id = ?', + [id, req.userId] + ) + if ((result as any).affectedRows === 0) { + return res.status(404).json({ code: 40400, message: '标签不存在' }) + } + + res.json({ code: 0 }) + } catch (err) { + console.error('[Tag] DELETE error:', err) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + +/** 按标签统计 */ +router.get('/stats/by-tag', async (req: AuthRequest, res: Response) => { + try { + const { month, period = 'month', type = 'expense' } = req.query + + if (!['expense', 'income'].includes(type as string)) { + return res.status(400).json({ code: 40001, message: '类型无效' }) + } + + let startDate: string + let endDate: string + + const now = new Date() + const m = (month as string) || getCurrentMonth() + + if (period === 'week') { + // 本周一到本周日 + const dayOfWeek = now.getDay() || 7 // 周日=7 + const monday = new Date(now) + monday.setDate(now.getDate() - dayOfWeek + 1) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + startDate = monday.toISOString().slice(0, 10) + endDate = sunday.toISOString().slice(0, 10) + } else if (period === 'year') { + // 当年1月1日到12月31日 + const year = now.getFullYear() + startDate = `${year}-01-01` + endDate = `${year}-12-31` + } else { + // month(默认) + const range = getMonthRange(m) + if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' }) + startDate = range.startDate + endDate = range.endDate + } + + const [rows] = await pool.query( + `SELECT tg.id, tg.name, tg.color, + COALESCE(SUM(t.amount), 0) as amount, + COUNT(DISTINCT t.id) as count + FROM tags tg + LEFT JOIN transaction_tags tt ON tg.id = tt.tag_id + LEFT JOIN transactions t ON tt.transaction_id = t.id AND t.type = ? AND t.date >= ? AND t.date <= ? + WHERE tg.user_id = ? + GROUP BY tg.id, tg.name, tg.color + HAVING count > 0 + ORDER BY amount DESC`, + [type, startDate, endDate, req.userId] + ) + + res.json({ code: 0, data: { list: rows } }) + } catch (err) { + console.error('[Tag] stats/by-tag error:', err) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + +export default router diff --git a/server/src/routes/transaction.ts b/server/src/routes/transaction.ts index c12abda..ed0b494 100644 --- a/server/src/routes/transaction.ts +++ b/server/src/routes/transaction.ts @@ -33,6 +33,17 @@ router.get('/:id', async (req: AuthRequest, res: Response) => { if (!tx) { return res.status(404).json({ code: 40400, message: '记录不存在' }) } + + // 查询关联的标签 + const [tagRows] = await pool.query( + `SELECT tg.id, tg.name, tg.color + FROM transaction_tags tt + INNER JOIN tags tg ON tt.tag_id = tg.id + WHERE tt.transaction_id = ?`, + [req.params.id] + ) + tx.tags = tagRows + res.json({ code: 0, data: tx }) } catch (err) { console.error('[Transaction] GET by ID error:', err) @@ -42,7 +53,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => { router.get('/', async (req: AuthRequest, res: Response) => { try { - const { page, pageSize, type, startDate, endDate, sortBy, group_id, category_id, category_ids, minAmount, maxAmount, keyword } = req.query + const { page, pageSize, type, startDate, endDate, sortBy, group_id, category_id, category_ids, minAmount, maxAmount, keyword, tagId } = req.query const pSize = safeInt(pageSize, 20, 1, 100) const pNum = safeInt(page, 1, 1, 99999) const offset = (pNum - 1) * pSize @@ -105,6 +116,17 @@ router.get('/', async (req: AuthRequest, res: Response) => { where += ' AND t.note LIKE ?'; params.push(`%${escapedKeyword}%`) } + // 标签筛选 + let tagJoin = '' + if (tagId) { + const tid = parseInt(tagId as string) + if (!isNaN(tid)) { + tagJoin = ' INNER JOIN transaction_tags tt_filter ON t.id = tt_filter.transaction_id' + where += ' AND tt_filter.tag_id = ?' + params.push(tid) + } + } + const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC' const [rows] = await pool.query( @@ -114,6 +136,7 @@ router.get('/', async (req: AuthRequest, res: Response) => { FROM transactions t LEFT JOIN categories c ON t.category_id = c.id LEFT JOIN users u ON t.user_id = u.id + ${tagJoin} ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, @@ -121,7 +144,7 @@ router.get('/', async (req: AuthRequest, res: Response) => { ) const [countResult] = await pool.query( - `SELECT COUNT(*) as total FROM transactions t ${where}`, + `SELECT COUNT(*) as total FROM transactions t ${tagJoin} ${where}`, params ) @@ -130,7 +153,7 @@ router.get('/', async (req: AuthRequest, res: Response) => { `SELECT COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as totalExpense, COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as totalIncome - FROM transactions t ${where}`, + FROM transactions t ${tagJoin} ${where}`, params ) @@ -153,7 +176,7 @@ router.get('/', async (req: AuthRequest, res: Response) => { router.post('/', async (req: AuthRequest, res: Response) => { try { - const { amount, type, category_id, note, date, group_id } = req.body + const { amount, type, category_id, note, date, group_id, tagIds } = req.body if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) { return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数(单位:分)' }) } @@ -182,21 +205,170 @@ router.post('/', async (req: AuthRequest, res: Response) => { } } - const [result] = await pool.query( - 'INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)', - [req.userId, amount, type, category_id || null, note || null, date, validGroupId] - ) + // 校验 tagIds + let validTagIds: number[] = [] + if (tagIds && Array.isArray(tagIds)) { + if (tagIds.length > 5) { + return res.status(400).json({ code: 40001, message: '标签数量不能超过5个' }) + } + const numTagIds = tagIds.map((id: any) => Number(id)).filter((id: number) => Number.isInteger(id) && id > 0) + if (numTagIds.length > 0) { + // 校验标签属于当前用户 + const [tagRows] = await pool.query( + `SELECT id FROM tags WHERE id IN (${numTagIds.map(() => '?').join(',')}) AND user_id = ?`, + [...numTagIds, req.userId] + ) + validTagIds = (tagRows as any[]).map(r => r.id) + } + } - res.json({ code: 0, data: { id: (result as any).insertId } }) + const conn = await pool.getConnection() + try { + await conn.beginTransaction() + + const [result] = await conn.query( + 'INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)', + [req.userId, amount, type, category_id || null, note || null, date, validGroupId] + ) + + const insertId = (result as any).insertId + + // 批量插入 transaction_tags + if (validTagIds.length > 0) { + const values = validTagIds.map(tagId => [insertId, tagId]) + await conn.query( + `INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ${values.map(() => '(?, ?)').join(', ')}`, + values.flat() + ) + } + + await conn.commit() + res.json({ code: 0, data: { id: insertId } }) + } catch (err) { + await conn.rollback() + throw err + } finally { + conn.release() + } } catch (err) { console.error('[Transaction] POST error:', err) res.status(500).json({ code: 50000, message: '服务器错误' }) } }) +// POST /import 必须在 /:id 之前注册,避免 Express 将 "import" 误当作 :id 参数 +router.post('/import', async (req: AuthRequest, res: Response) => { + try { + const { transactions } = req.body + if (!Array.isArray(transactions) || transactions.length === 0) { + return res.status(400).json({ code: 40001, message: '缺少交易数据' }) + } + if (transactions.length > 1000) { + return res.status(400).json({ code: 40001, message: '单次导入不能超过1000条' }) + } + + // 逐条校验 & 去重 + const validItems: Array<{ date: string; type: string; category_name: string; amount: number; note: string }> = [] + const seen = new Set() + let skipped = 0 + + for (const item of transactions) { + // 校验必填字段 + if (!item.date || !item.type || item.amount === undefined || item.amount === null) { + skipped++ + continue + } + // 校验日期格式 + if (!DATE_REGEX.test(String(item.date))) { + skipped++ + continue + } + // 校验类型 + if (!VALID_TYPES.includes(item.type)) { + skipped++ + continue + } + // 校验金额 + const amt = Number(item.amount) + if (isNaN(amt) || !Number.isInteger(amt) || amt <= 0 || amt > 999999999) { + skipped++ + continue + } + + // 去重:按 date+type+amount+note 组合判重 + const noteVal = item.note ? String(item.note).slice(0, 200) : '' + const dedupeKey = `${item.date}:${item.type}:${amt}:${noteVal}` + if (seen.has(dedupeKey)) { + skipped++ + continue + } + seen.add(dedupeKey) + + validItems.push({ + date: String(item.date), + type: item.type, + category_name: item.category_name ? String(item.category_name) : '', + amount: amt, + note: noteVal + }) + } + + if (validItems.length === 0) { + return res.json({ code: 0, data: { imported: 0, skipped } }) + } + + // 预加载用户的分类映射(category_name → category_id) + const [catRows] = await pool.query( + 'SELECT id, name FROM categories WHERE user_id = ?', + [req.userId] + ) + const catMap = new Map() + for (const row of catRows as any[]) { + catMap.set(row.name, row.id) + } + + const conn = await pool.getConnection() + try { + await conn.beginTransaction() + + let imported = 0 + const BATCH_SIZE = 100 + + for (let i = 0; i < validItems.length; i += BATCH_SIZE) { + const batch = validItems.slice(i, i + BATCH_SIZE) + const values: any[] = [] + const placeholders: string[] = [] + + for (const item of batch) { + const categoryId = catMap.get(item.category_name) || null + placeholders.push('(?, ?, ?, ?, ?, ?, ?)') + values.push(req.userId, item.amount, item.type, categoryId, item.note || null, item.date, null) + } + + await conn.query( + `INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id) VALUES ${placeholders.join(', ')}`, + values + ) + imported += batch.length + } + + await conn.commit() + res.json({ code: 0, data: { imported, skipped } }) + } catch (err) { + await conn.rollback() + throw err + } finally { + conn.release() + } + } catch (err) { + console.error('[Transaction] POST /import error:', err) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + router.put('/:id', async (req: AuthRequest, res: Response) => { try { - const { amount, type, category_id, note, date } = req.body + const { amount, type, category_id, note, date, tagIds } = req.body if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) { return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数(单位:分)' }) } @@ -212,14 +384,64 @@ router.put('/:id', async (req: AuthRequest, res: Response) => { if (note && note.length > 200) { return res.status(400).json({ code: 40004, message: '备注不能超过200字' }) } - const [result] = await pool.query( - 'UPDATE transactions SET amount = ?, type = ?, category_id = ?, note = ?, date = ? WHERE id = ? AND user_id = ?', - [amount, type, category_id || null, note || null, date, req.params.id, req.userId] - ) - if ((result as any).affectedRows === 0) { - return res.status(404).json({ code: 40400, message: '记录不存在' }) + + // 校验 tagIds + let validTagIds: number[] | null = null + if (tagIds !== undefined) { + if (!Array.isArray(tagIds)) { + return res.status(400).json({ code: 40001, message: 'tagIds格式无效' }) + } + if (tagIds.length > 5) { + return res.status(400).json({ code: 40001, message: '标签数量不能超过5个' }) + } + const numTagIds = tagIds.map((id: any) => Number(id)).filter((id: number) => Number.isInteger(id) && id > 0) + if (numTagIds.length > 0) { + const [tagRows] = await pool.query( + `SELECT id FROM tags WHERE id IN (${numTagIds.map(() => '?').join(',')}) AND user_id = ?`, + [...numTagIds, req.userId] + ) + validTagIds = (tagRows as any[]).map(r => r.id) + } else { + validTagIds = [] + } + } + + const conn = await pool.getConnection() + try { + await conn.beginTransaction() + + const [result] = await conn.query( + 'UPDATE transactions SET amount = ?, type = ?, category_id = ?, note = ?, date = ? WHERE id = ? AND user_id = ?', + [amount, type, category_id || null, note || null, date, req.params.id, req.userId] + ) + if ((result as any).affectedRows === 0) { + await conn.rollback() + return res.status(404).json({ code: 40400, message: '记录不存在' }) + } + + // 全量替换 tagIds + if (validTagIds !== null) { + await conn.query( + 'DELETE FROM transaction_tags WHERE transaction_id = ?', + [req.params.id] + ) + if (validTagIds.length > 0) { + const values = validTagIds.map(tagId => [req.params.id, tagId]) + await conn.query( + `INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ${values.map(() => '(?, ?)').join(', ')}`, + values.flat() + ) + } + } + + await conn.commit() + res.json({ code: 0 }) + } catch (err) { + await conn.rollback() + throw err + } finally { + conn.release() } - res.json({ code: 0 }) } catch (err) { console.error('[Transaction] PUT error:', err) res.status(500).json({ code: 50000, message: '服务器错误' })