Compare commits
3 Commits
dev
...
ae9b415822
| Author | SHA1 | Date | |
|---|---|---|---|
| ae9b415822 | |||
| 31f6487d61 | |||
| 5df910c9f1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,3 +40,6 @@ server/uploads/
|
|||||||
# Playwright
|
# Playwright
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
server/backups/
|
server/backups/
|
||||||
|
|
||||||
|
# Workbuddy
|
||||||
|
.workbuddy/
|
||||||
|
|||||||
@@ -54,3 +54,11 @@ export function updateUserRole(id: number, role: 'user' | 'admin') {
|
|||||||
export function deleteUser(id: number) {
|
export function deleteUser(id: number) {
|
||||||
return request({ url: `/admin/users/${id}`, method: 'DELETE' })
|
return request({ url: `/admin/users/${id}`, method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 健康检查类型从 @/api/health 导入
|
||||||
|
export type { HealthStatus } from '@/api/health'
|
||||||
|
|
||||||
|
/** 获取服务健康状态(管理员) */
|
||||||
|
export function getHealth() {
|
||||||
|
return request<import('./health').HealthStatus>({ url: '/health' })
|
||||||
|
}
|
||||||
|
|||||||
38
client/src/api/backup.ts
Normal file
38
client/src/api/backup.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
/** 备份记录 */
|
||||||
|
export interface Backup {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
status: 'pending' | 'completed' | 'failed'
|
||||||
|
created_at: string
|
||||||
|
downloadToken?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 备份列表响应 */
|
||||||
|
export interface BackupList {
|
||||||
|
list: Backup[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取备份列表 */
|
||||||
|
export function getBackups(params?: { page?: number; pageSize?: number }) {
|
||||||
|
return request<BackupList>({ url: '/backup', data: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建备份 */
|
||||||
|
export function createBackup() {
|
||||||
|
return request<{ id: number }>({ url: '/backup', method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除备份 */
|
||||||
|
export function deleteBackup(id: number) {
|
||||||
|
return request({ url: `/backup/${id}`, method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取备份下载地址 */
|
||||||
|
export function getBackupDownloadUrl(id: number, token: string): string {
|
||||||
|
const apiBase = typeof window !== 'undefined' ? '/api' : ''
|
||||||
|
return `${apiBase}/backup/${id}/download?token=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
32
client/src/api/export.ts
Normal file
32
client/src/api/export.ts
Normal file
@@ -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()}`
|
||||||
|
}
|
||||||
@@ -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 })
|
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) {
|
export function updateFeedbackStatus(id: number, status: Feedback['status'], admin_reply?: string) {
|
||||||
return request({ url: `/feedback/${id}/status`, method: 'PUT', data: { status, admin_reply } })
|
return request({ url: `/feedback/${id}/status`, method: 'PUT', data: { status, admin_reply } })
|
||||||
|
|||||||
15
client/src/api/health.ts
Normal file
15
client/src/api/health.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
/** 健康检查结果 */
|
||||||
|
export interface HealthStatus {
|
||||||
|
status: 'ok' | 'degraded' | 'error'
|
||||||
|
uptime: number
|
||||||
|
db: 'connected' | 'disconnected'
|
||||||
|
version: string
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取服务健康状态 */
|
||||||
|
export function getHealth() {
|
||||||
|
return request<HealthStatus>({ url: '/health' })
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export interface CategoryStat {
|
|||||||
export interface TrendPoint {
|
export interface TrendPoint {
|
||||||
date: string
|
date: string
|
||||||
amount: number
|
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) {
|
export function getCategoryStats(month?: string, type: string = 'expense', group_id?: number | null, period?: 'week' | 'month' | 'year') {
|
||||||
return request<CategoryStat[]>({ url: '/stats/category', data: { month, type, group_id } })
|
return request<CategoryStat[]>({ url: '/stats/category', data: { month, type, group_id, period } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取趋势数据 */
|
/** 获取趋势数据 */
|
||||||
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) {
|
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null, period?: 'week' | 'month' | 'year') {
|
||||||
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } })
|
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id, period } })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 智能分类建议结果 */
|
/** 智能分类建议结果 */
|
||||||
|
|||||||
47
client/src/api/tag.ts
Normal file
47
client/src/api/tag.ts
Normal file
@@ -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<Tag[]>({ url: '/tags' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建标签 */
|
||||||
|
export function createTag(data: { name: string; color: string }) {
|
||||||
|
return request<{ id: number }>({ url: '/tags', method: 'POST', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新标签 */
|
||||||
|
export function updateTag(id: number, data: { name?: string; color?: string }) {
|
||||||
|
return request({ url: `/tags/${id}`, method: 'PUT', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除标签 */
|
||||||
|
export function deleteTag(id: number) {
|
||||||
|
return request({ url: `/tags/${id}`, method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按标签统计 */
|
||||||
|
export function getTagStats(params?: {
|
||||||
|
month?: string
|
||||||
|
period?: 'week' | 'month' | 'year'
|
||||||
|
type?: 'expense' | 'income'
|
||||||
|
}) {
|
||||||
|
return request<TagStat[]>({ url: '/tags/stats/by-tag', data: params })
|
||||||
|
}
|
||||||
@@ -15,6 +15,14 @@ export interface Transaction {
|
|||||||
group_id?: number | null
|
group_id?: number | null
|
||||||
creator_nickname?: string
|
creator_nickname?: string
|
||||||
creator_avatar?: string
|
creator_avatar?: string
|
||||||
|
tags?: TagItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标签简略信息 */
|
||||||
|
export interface TagItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 交易列表响应 */
|
/** 交易列表响应 */
|
||||||
@@ -40,6 +48,7 @@ export interface TransactionParams {
|
|||||||
minAmount?: number
|
minAmount?: number
|
||||||
maxAmount?: number
|
maxAmount?: number
|
||||||
keyword?: string
|
keyword?: string
|
||||||
|
tagId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取交易列表 */
|
/** 获取交易列表 */
|
||||||
@@ -60,6 +69,7 @@ export function createTransaction(data: {
|
|||||||
note?: string
|
note?: string
|
||||||
date: string
|
date: string
|
||||||
group_id?: number | null
|
group_id?: number | null
|
||||||
|
tagIds?: number[]
|
||||||
}) {
|
}) {
|
||||||
return request<{ id: number }>({ url: '/transactions', method: 'POST', data })
|
return request<{ id: number }>({ url: '/transactions', method: 'POST', data })
|
||||||
}
|
}
|
||||||
@@ -71,6 +81,7 @@ export function updateTransaction(id: number, data: {
|
|||||||
category_id?: number
|
category_id?: number
|
||||||
note?: string
|
note?: string
|
||||||
date: string
|
date: string
|
||||||
|
tagIds?: number[]
|
||||||
}) {
|
}) {
|
||||||
return request({ url: `/transactions/${id}`, method: 'PUT', data })
|
return request({ url: `/transactions/${id}`, method: 'PUT', data })
|
||||||
}
|
}
|
||||||
@@ -79,3 +90,16 @@ export function updateTransaction(id: number, data: {
|
|||||||
export function deleteTransaction(id: number) {
|
export function deleteTransaction(id: number) {
|
||||||
return request({ url: `/transactions/${id}`, method: 'DELETE' })
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="budget-bar-wrap">
|
<view class="budget-bar-wrap">
|
||||||
<view class="bar-track">
|
<view class="bar-track">
|
||||||
<view class="bar-fill" :style="{ width: percentage + '%', background: fillColor }"></view>
|
<view class="bar-fill" :class="{ over: rawPercentage > 100 }" :style="{ width: percentage + '%' }"></view>
|
||||||
</view>
|
</view>
|
||||||
<view class="bar-info" v-if="showLabel">
|
<view class="bar-info" v-if="showLabel">
|
||||||
<text class="bar-text" v-if="rawPercentage <= 100">预算 {{ formatAmount(total) }} · 剩余 {{ formatAmount(Math.max(0, total - used)) }}</text>
|
<text class="bar-text" v-if="rawPercentage <= 100">预算 {{ formatAmount(total) }} · 剩余 {{ formatAmount(Math.max(0, total - used)) }}</text>
|
||||||
@@ -27,25 +27,25 @@ const rawPercentage = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const percentage = computed(() => Math.min(100, rawPercentage.value))
|
const percentage = computed(() => Math.min(100, rawPercentage.value))
|
||||||
|
|
||||||
const fillColor = computed(() => {
|
|
||||||
if (rawPercentage.value > 100) return '#FF6B6B'
|
|
||||||
return 'linear-gradient(90deg, #FF8C69, #FFB89A)'
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.bar-track {
|
.bar-track {
|
||||||
height: 16rpx;
|
height: 16rpx;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
background: #F0E0D6;
|
background: $border;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-fill {
|
.bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
transition: width 0.6s ease-out;
|
background: linear-gradient(90deg, $primary, lighten($primary, 15%));
|
||||||
|
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&.over {
|
||||||
|
background: $danger;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-info {
|
.bar-info {
|
||||||
@@ -56,20 +56,20 @@ const fillColor = computed(() => {
|
|||||||
|
|
||||||
.bar-text {
|
.bar-text {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-pct {
|
.bar-pct {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-family: 'Fredoka', sans-serif;
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
|
|
||||||
&.over { color: #FF6B6B; }
|
&.over { color: $danger; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-text.over {
|
.bar-text.over {
|
||||||
color: #FF6B6B;
|
color: $danger;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const iconStyle = computed(() => ({
|
|||||||
background: props.selected ? '#FFE8E0' : (props.bgColor || (props.color + '15')),
|
background: props.selected ? '#FFE8E0' : (props.bgColor || (props.color + '15')),
|
||||||
width: (sizeMap[props.size || 'md']) + 'rpx',
|
width: (sizeMap[props.size || 'md']) + 'rpx',
|
||||||
height: (sizeMap[props.size || 'md']) + 'rpx',
|
height: (sizeMap[props.size || 'md']) + 'rpx',
|
||||||
|
boxShadow: props.selected ? '0 0 0 4rpx rgba(255, 140, 105, 0.15)' : 'none',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const fontSize = computed(() => (fontMap[props.size || 'md']) + 'rpx')
|
const fontSize = computed(() => (fontMap[props.size || 'md']) + 'rpx')
|
||||||
@@ -42,6 +43,7 @@ const fontSize = computed(() => (fontMap[props.size || 'md']) + 'rpx')
|
|||||||
}
|
}
|
||||||
|
|
||||||
.selected {
|
.selected {
|
||||||
border-color: #FF8C69;
|
border-color: $primary;
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
144
client/src/components/DragSortList/DragSortList.vue
Normal file
144
client/src/components/DragSortList/DragSortList.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<view class="drag-sort-list">
|
||||||
|
<movable-area class="drag-area">
|
||||||
|
<movable-view
|
||||||
|
v-for="(item, index) in list"
|
||||||
|
:key="itemKey ? item[itemKey] : index"
|
||||||
|
class="drag-item"
|
||||||
|
:class="{ dragging: dragIndex === index }"
|
||||||
|
direction="vertical"
|
||||||
|
:y="positions[index] || 0"
|
||||||
|
:damping="40"
|
||||||
|
:disabled="disabled"
|
||||||
|
@touchstart="onDragStart(index)"
|
||||||
|
@change="onChange(index, $event)"
|
||||||
|
@touchend="onDragEnd(index)"
|
||||||
|
>
|
||||||
|
<view class="drag-handle" v-if="!disabled">
|
||||||
|
<Icon name="dragHandle" :size="28" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
|
<slot name="item" :item="item" :index="index">
|
||||||
|
<text class="drag-default-text">{{ item[itemKey] || item }}</text>
|
||||||
|
</slot>
|
||||||
|
</movable-view>
|
||||||
|
</movable-area>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
list: any[]
|
||||||
|
itemKey?: string
|
||||||
|
disabled?: boolean
|
||||||
|
itemHeight?: number
|
||||||
|
}>(), {
|
||||||
|
disabled: false,
|
||||||
|
itemHeight: 56
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'sort', newOrder: any[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** 每个 item 的 y 坐标 */
|
||||||
|
const positions = ref<number[]>([])
|
||||||
|
/** 当前正在拖拽的 index */
|
||||||
|
const dragIndex = ref<number | null>(null)
|
||||||
|
/** 拖拽起始 y */
|
||||||
|
let startY = 0
|
||||||
|
/** 当前排序列表 */
|
||||||
|
const sortedList = ref<any[]>([])
|
||||||
|
|
||||||
|
watch(() => props.list, (newList) => {
|
||||||
|
sortedList.value = [...newList]
|
||||||
|
recalcPositions()
|
||||||
|
}, { immediate: true, deep: true })
|
||||||
|
|
||||||
|
function recalcPositions() {
|
||||||
|
const pos: number[] = []
|
||||||
|
for (let i = 0; i < sortedList.value.length; i++) {
|
||||||
|
pos.push(i * props.itemHeight)
|
||||||
|
}
|
||||||
|
positions.value = pos
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragStart(index: number) {
|
||||||
|
dragIndex.value = index
|
||||||
|
startY = positions.value[index] || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(index: number, e: any) {
|
||||||
|
if (e.detail.source !== 'touch') return
|
||||||
|
const newY = e.detail.y
|
||||||
|
const currentPos = index * props.itemHeight
|
||||||
|
const delta = newY - currentPos
|
||||||
|
const swapThreshold = props.itemHeight * 0.5
|
||||||
|
|
||||||
|
if (Math.abs(delta) > swapThreshold) {
|
||||||
|
const direction = delta > 0 ? 1 : -1
|
||||||
|
const swapIndex = index + direction
|
||||||
|
if (swapIndex < 0 || swapIndex >= sortedList.value.length) return
|
||||||
|
|
||||||
|
// 交换元素
|
||||||
|
const newList = [...sortedList.value]
|
||||||
|
const temp = newList[index]
|
||||||
|
newList[index] = newList[swapIndex]
|
||||||
|
newList[swapIndex] = temp
|
||||||
|
sortedList.value = newList
|
||||||
|
recalcPositions()
|
||||||
|
emit('sort', newList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd(index: number) {
|
||||||
|
dragIndex.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.drag-sort-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-area {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
min-height: 200rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
margin-bottom: $space-xs;
|
||||||
|
transition: box-shadow $transition-normal;
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
box-shadow: $shadow-lg;
|
||||||
|
opacity: 0.9;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-default-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -50,6 +50,7 @@ const iconMap: Record<string, Record<string, string>> = {
|
|||||||
},
|
},
|
||||||
check: {
|
check: {
|
||||||
'#FFFFFF': '/static/icons/check-white.png',
|
'#FFFFFF': '/static/icons/check-white.png',
|
||||||
|
'#FF8C69': '/static/icons/check-orange.png',
|
||||||
},
|
},
|
||||||
trash: {
|
trash: {
|
||||||
'#FF8C69': '/static/icons/trash-orange.png',
|
'#FF8C69': '/static/icons/trash-orange.png',
|
||||||
@@ -71,6 +72,25 @@ const iconMap: Record<string, Record<string, string>> = {
|
|||||||
'#8B7E7E': '/static/icons/settings-gray.png',
|
'#8B7E7E': '/static/icons/settings-gray.png',
|
||||||
'#FF8C69': '/static/icons/settings-orange.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(() => {
|
const iconSrc = computed(() => {
|
||||||
|
|||||||
@@ -207,10 +207,10 @@ function onConfirm() {
|
|||||||
|
|
||||||
.key.fn {
|
.key.fn {
|
||||||
background: $primary-light;
|
background: $primary-light;
|
||||||
border-color: #FFD0C0;
|
border-color: darken($primary-light, 8%);
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background: #FFD0C0;
|
background: darken($primary-light, 8%);
|
||||||
box-shadow: $shadow-clay-pressed;
|
box-shadow: $shadow-clay-pressed;
|
||||||
transform: scale(0.96);
|
transform: scale(0.96);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="save-success" v-if="visible">
|
<view class="save-success" v-if="visible">
|
||||||
|
<!-- 纸屑粒子 -->
|
||||||
|
<view class="confetti-container" v-if="animating">
|
||||||
|
<view
|
||||||
|
v-for="i in 20"
|
||||||
|
:key="i"
|
||||||
|
class="confetti"
|
||||||
|
:class="`confetti-${i}`"
|
||||||
|
:style="confettiStyle(i)"
|
||||||
|
></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="success-content" :class="{ show: animating }">
|
<view class="success-content" :class="{ show: animating }">
|
||||||
|
<view class="success-icon-wrap">
|
||||||
|
<view class="success-ring"></view>
|
||||||
<view class="success-icon">
|
<view class="success-icon">
|
||||||
<Icon name="check" :size="80" color="#FFFFFF" />
|
<Icon name="check" :size="80" color="#FFFFFF" />
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
<text class="success-text">{{ text }}</text>
|
<text class="success-text">{{ text }}</text>
|
||||||
<view class="success-actions" v-if="showContinue">
|
<view class="success-actions" v-if="showContinue">
|
||||||
<view class="action-btn continue" @tap="onContinue">
|
<view class="action-btn continue" @tap="onContinue">
|
||||||
@@ -31,12 +45,33 @@ const emit = defineEmits(['update:visible', 'continue', 'back'])
|
|||||||
|
|
||||||
const animating = ref(false)
|
const animating = ref(false)
|
||||||
|
|
||||||
|
// 纸屑样式:随机位置、颜色、动画延迟
|
||||||
|
const confettiColors = ['#FF8C69', '#FFD166', '#7BC67E', '#6C9BCF', '#FF69B4', '#FFB89A']
|
||||||
|
function confettiStyle(i: number) {
|
||||||
|
const angle = (i / 20) * 360
|
||||||
|
const distance = 120 + Math.random() * 200
|
||||||
|
const color = confettiColors[i % confettiColors.length]
|
||||||
|
const delay = Math.random() * 0.3
|
||||||
|
const size = 8 + Math.random() * 12
|
||||||
|
const isRect = i % 3 === 0
|
||||||
|
return {
|
||||||
|
'--tx': `${Math.cos(angle * Math.PI / 180) * distance}rpx`,
|
||||||
|
'--ty': `${Math.sin(angle * Math.PI / 180) * distance - 100}rpx`,
|
||||||
|
'--rot': `${Math.random() * 720 - 360}deg`,
|
||||||
|
background: color,
|
||||||
|
width: isRect ? `${size}rpx` : `${size * 0.6}rpx`,
|
||||||
|
height: `${size}rpx`,
|
||||||
|
borderRadius: isRect ? '2rpx' : '50%',
|
||||||
|
animationDelay: `${delay}s`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
watch(() => props.visible, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
setTimeout(() => { animating.value = true }, 50)
|
setTimeout(() => { animating.value = true }, 50)
|
||||||
// 如果不显示继续按钮,自动关闭
|
// 如果不显示继续按钮,自动关闭
|
||||||
if (!props.showContinue) {
|
if (!props.showContinue) {
|
||||||
setTimeout(() => { onBack() }, 2000)
|
setTimeout(() => { onBack() }, 2200)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
animating.value = false
|
animating.value = false
|
||||||
@@ -74,13 +109,44 @@ function onBack() {
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 纸屑容器
|
||||||
|
.confetti-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confetti {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
animation: confetti-burst 0.8s cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes confetti-burst {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0) rotate(0deg) scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.success-content {
|
.success-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transform: scale(0.8);
|
transform: scale(0.8);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
&.show {
|
&.show {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
@@ -88,12 +154,42 @@ function onBack() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 图标外环脉冲
|
||||||
|
.success-icon-wrap {
|
||||||
|
position: relative;
|
||||||
|
@include flex-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-ring {
|
||||||
|
position: absolute;
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4rpx solid rgba(123, 198, 126, 0.3);
|
||||||
|
animation: ring-pulse 1s ease-out 0.3s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ring-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.8);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.success-icon {
|
.success-icon {
|
||||||
width: 160rpx;
|
width: 160rpx;
|
||||||
height: 160rpx;
|
height: 160rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, $success, #5BA85E);
|
background: linear-gradient(135deg, $success, darken($success, 10%));
|
||||||
@include flex-center;
|
@include flex-center;
|
||||||
|
box-shadow: 0 8rpx 32rpx rgba(123, 198, 126, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-text {
|
.success-text {
|
||||||
@@ -114,15 +210,16 @@ function onBack() {
|
|||||||
border-radius: $radius-xl;
|
border-radius: $radius-xl;
|
||||||
min-width: 200rpx;
|
min-width: 200rpx;
|
||||||
@include flex-center;
|
@include flex-center;
|
||||||
|
transition: transform $transition-fast, opacity $transition-fast;
|
||||||
|
|
||||||
&.continue {
|
&.continue {
|
||||||
background: $primary;
|
background: $primary;
|
||||||
&:active { opacity: 0.8; }
|
&:active { opacity: 0.8; transform: scale(0.96); }
|
||||||
}
|
}
|
||||||
|
|
||||||
&.back {
|
&.back {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
&:active { background: rgba(255, 255, 255, 0.3); }
|
&:active { background: rgba(255, 255, 255, 0.3); transform: scale(0.96); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,4 +228,10 @@ function onBack() {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $surface;
|
color: $surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.confetti { animation: none !important; display: none; }
|
||||||
|
.success-ring { animation: none !important; display: none; }
|
||||||
|
.success-content { transition: opacity 0.2s ease; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const style = computed(() => ({
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.skeleton {
|
.skeleton {
|
||||||
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
|
background: linear-gradient(90deg, $border 25%, $surface-warm 50%, $border 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s infinite;
|
animation: shimmer 1.5s infinite;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="tx-item-wrap">
|
<view class="tx-item-wrap" :class="{ 'show-hint': swipeHint && !disableSwipe }">
|
||||||
<view
|
<view
|
||||||
class="tx-item"
|
class="tx-item"
|
||||||
:style="{ transform: `translateX(${offsetX}rpx)` }"
|
:style="{ transform: `translateX(${offsetX}rpx)` }"
|
||||||
@@ -61,7 +61,9 @@ const props = withDefaults(defineProps<{
|
|||||||
showCreator?: boolean
|
showCreator?: boolean
|
||||||
/** 当前用户 ID,用于判断是否为本人记录 */
|
/** 当前用户 ID,用于判断是否为本人记录 */
|
||||||
currentUserId?: number
|
currentUserId?: number
|
||||||
}>(), { hideDate: false, disableSwipe: false, showCreator: false, currentUserId: 0 })
|
/** 是否显示左滑提示动画(由父组件控制,仅首个可删除项传入true) */
|
||||||
|
swipeHint?: boolean
|
||||||
|
}>(), { hideDate: false, disableSwipe: false, showCreator: false, currentUserId: 0, swipeHint: false })
|
||||||
|
|
||||||
/** 是否为他人的记录(群组模式下,非本人的才显示创建者信息) */
|
/** 是否为他人的记录(群组模式下,非本人的才显示创建者信息) */
|
||||||
const isOtherCreator = computed(() => {
|
const isOtherCreator = computed(() => {
|
||||||
@@ -108,6 +110,8 @@ function onTouchEnd() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
.tx-item-wrap {
|
.tx-item-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -121,22 +125,33 @@ function onTouchEnd() {
|
|||||||
gap: 24rpx;
|
gap: 24rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
background: #FFFFFF;
|
background: $surface;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background: #FFF0E6;
|
background: $surface-warm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 左滑提示:仅在 swipeHint=true 且 disableSwipe=false 时播放一次
|
||||||
|
.show-hint .tx-item {
|
||||||
|
animation: swipe-hint 0.6s ease 1.5s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes swipe-hint {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
40% { transform: translateX(-40rpx); }
|
||||||
|
100% { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 160rpx;
|
width: 160rpx;
|
||||||
background: #FF6B6B;
|
background: $danger;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -169,9 +184,9 @@ function onTouchEnd() {
|
|||||||
width: 28rpx;
|
width: 28rpx;
|
||||||
height: 28rpx;
|
height: 28rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #FFF0E6;
|
background: $primary-light;
|
||||||
font-size: 16rpx;
|
font-size: 16rpx;
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -185,22 +200,20 @@ function onTouchEnd() {
|
|||||||
.name {
|
.name {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #2D1B1B;
|
color: $text;
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
@include text-ellipsis;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: #8B7E7E;
|
color: $text-sec;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4rpx;
|
margin-top: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.creator-name {
|
.creator-name {
|
||||||
color: #FF8C69;
|
color: $primary;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +224,13 @@ function onTouchEnd() {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&.expense { color: #2D1B1B; }
|
&.expense { color: $text; }
|
||||||
&.income { color: #7BC67E; }
|
&.income { color: $success; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.show-hint .tx-item {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -154,6 +154,29 @@
|
|||||||
"navigationBarTitleText": "埋点统计",
|
"navigationBarTitleText": "埋点统计",
|
||||||
"enablePullDownRefresh": true
|
"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": {
|
"globalStyle": {
|
||||||
|
|||||||
@@ -25,8 +25,6 @@
|
|||||||
:fixed="true"
|
:fixed="true"
|
||||||
size="large"
|
size="large"
|
||||||
:auto-focus="true"
|
:auto-focus="true"
|
||||||
@focus="keyboardVisible = true"
|
|
||||||
@blur="keyboardVisible = false"
|
|
||||||
@confirm="save"
|
@confirm="save"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -55,14 +53,21 @@
|
|||||||
<Icon name="edit" :size="28" color="#8B7E7E" />
|
<Icon name="edit" :size="28" color="#8B7E7E" />
|
||||||
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="blurAmountEditor" />
|
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="blurAmountEditor" />
|
||||||
</view>
|
</view>
|
||||||
|
<view class="extra-row" @tap="openTagPicker">
|
||||||
|
<Icon name="tag" :size="28" color="#8B7E7E" />
|
||||||
|
<view class="tag-display">
|
||||||
|
<text class="extra-text" v-if="selectedTagIds.length === 0">选择标签(选填)</text>
|
||||||
|
<view class="tag-chips" v-else>
|
||||||
|
<view v-for="tId in selectedTagIds" :key="tId" class="tag-chip" :style="{ background: (getTagById(tId)?.color || '#FF8C69') + '20', color: getTagById(tId)?.color || '#FF8C69' }">
|
||||||
|
<text class="tag-chip-text">{{ getTagById(tId)?.name || '' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 底部保存按钮(键盘收起时显示) -->
|
|
||||||
<view class="save-bar" v-if="!showSuccess && !keyboardVisible">
|
|
||||||
<view class="save-btn" @tap="save">
|
|
||||||
<text class="save-btn-text">{{ editId ? '保存修改' : '保存' }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<SaveSuccess
|
<SaveSuccess
|
||||||
v-model:visible="showSuccess"
|
v-model:visible="showSuccess"
|
||||||
@@ -71,6 +76,33 @@
|
|||||||
@continue="onContinue"
|
@continue="onContinue"
|
||||||
@back="onBack"
|
@back="onBack"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 标签选择弹窗 -->
|
||||||
|
<view class="modal-mask" v-if="showTagPicker" @tap="showTagPicker = false">
|
||||||
|
<view class="tag-modal" @tap.stop>
|
||||||
|
<text class="tag-modal-title">选择标签</text>
|
||||||
|
<text class="tag-modal-hint">最多选择 5 个标签</text>
|
||||||
|
<scroll-view class="tag-modal-list" scroll-y>
|
||||||
|
<view
|
||||||
|
v-for="tag in tagStore.tags"
|
||||||
|
:key="tag.id"
|
||||||
|
class="tag-option"
|
||||||
|
:class="{ selected: selectedTagIds.includes(tag.id) }"
|
||||||
|
@tap="toggleTag(tag.id)"
|
||||||
|
>
|
||||||
|
<view class="tag-option-dot" :style="{ background: tag.color }"></view>
|
||||||
|
<text class="tag-option-name">{{ tag.name }}</text>
|
||||||
|
<Icon v-if="selectedTagIds.includes(tag.id)" name="check" :size="24" color="#FF8C69" />
|
||||||
|
</view>
|
||||||
|
<view class="tag-empty" v-if="tagStore.tags.length === 0">
|
||||||
|
<text class="tag-empty-text">暂无标签,可在"我的-标签管理"中添加</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
<view class="tag-modal-btns">
|
||||||
|
<view class="tag-modal-btn" @tap="showTagPicker = false">确定</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -79,6 +111,7 @@ import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
|||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { useCategoryStore } from '@/stores/category'
|
import { useCategoryStore } from '@/stores/category'
|
||||||
import { useTransactionStore } from '@/stores/transaction'
|
import { useTransactionStore } from '@/stores/transaction'
|
||||||
|
import { useTagStore } from '@/stores/tag'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { suggestCategory } from '@/api/stats'
|
import { suggestCategory } from '@/api/stats'
|
||||||
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
|
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
|
||||||
@@ -89,11 +122,14 @@ import { statusBarHeight, capsuleRight } from '@/utils/system'
|
|||||||
|
|
||||||
const catStore = useCategoryStore()
|
const catStore = useCategoryStore()
|
||||||
const txStore = useTransactionStore()
|
const txStore = useTransactionStore()
|
||||||
|
const tagStore = useTagStore()
|
||||||
|
|
||||||
const type = ref<'expense' | 'income'>('expense')
|
const type = ref<'expense' | 'income'>('expense')
|
||||||
const amountStr = ref('0')
|
const amountStr = ref('0')
|
||||||
const selectedCat = ref<number | null>(null)
|
const selectedCat = ref<number | null>(null)
|
||||||
const note = ref('')
|
const note = ref('')
|
||||||
|
const selectedTagIds = ref<number[]>([])
|
||||||
|
const showTagPicker = ref(false)
|
||||||
const suggestedCatId = ref<number | null>(null)
|
const suggestedCatId = ref<number | null>(null)
|
||||||
const suggestConfidence = ref(0)
|
const suggestConfidence = ref(0)
|
||||||
let suggestTimer: ReturnType<typeof setTimeout> | null = null
|
let suggestTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -106,8 +142,6 @@ const showSuccess = ref(false)
|
|||||||
const scrollToCat = ref('')
|
const scrollToCat = ref('')
|
||||||
const todayStr = localToday
|
const todayStr = localToday
|
||||||
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
|
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
|
||||||
const keyboardVisible = ref(false)
|
|
||||||
|
|
||||||
// Remember last selected category per type
|
// Remember last selected category per type
|
||||||
const lastCatByType: Record<string, number | null> = { expense: null, income: null }
|
const lastCatByType: Record<string, number | null> = { expense: null, income: null }
|
||||||
|
|
||||||
@@ -182,6 +216,13 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载标签列表
|
||||||
|
try {
|
||||||
|
await tagStore.fetchTags()
|
||||||
|
} catch {
|
||||||
|
// 标签加载失败不影响主流程
|
||||||
|
}
|
||||||
|
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
const page = pages[pages.length - 1] as { options?: Record<string, string> }
|
const page = pages[pages.length - 1] as { options?: Record<string, string> }
|
||||||
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {
|
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {
|
||||||
@@ -201,6 +242,7 @@ onMounted(async () => {
|
|||||||
lastCatByType[existing.type] = existing.category_id
|
lastCatByType[existing.type] = existing.category_id
|
||||||
note.value = existing.note || ''
|
note.value = existing.note || ''
|
||||||
selectedDate.value = existing.date.slice(0, 10)
|
selectedDate.value = existing.date.slice(0, 10)
|
||||||
|
selectedTagIds.value = existing.tags?.map(t => t.id) || []
|
||||||
// 等待分类列表渲染完成后滚动到选中项
|
// 等待分类列表渲染完成后滚动到选中项
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
|
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
|
||||||
@@ -220,7 +262,10 @@ onMounted(async () => {
|
|||||||
// 返回页面时刷新分类(可能新增了分类)
|
// 返回页面时刷新分类(可能新增了分类)
|
||||||
const initialLoaded = ref(false)
|
const initialLoaded = ref(false)
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
if (initialLoaded.value) catStore.fetchCategories()
|
if (initialLoaded.value) {
|
||||||
|
catStore.fetchCategories()
|
||||||
|
tagStore.fetchTags()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function onDateChange(e: any) {
|
function onDateChange(e: any) {
|
||||||
@@ -250,7 +295,8 @@ async function save() {
|
|||||||
type: type.value,
|
type: type.value,
|
||||||
category_id: selectedCat.value,
|
category_id: selectedCat.value,
|
||||||
note: note.value,
|
note: note.value,
|
||||||
date: selectedDate.value
|
date: selectedDate.value,
|
||||||
|
...(selectedTagIds.value.length > 0 ? { tagIds: selectedTagIds.value } : {})
|
||||||
}
|
}
|
||||||
if (editId.value) {
|
if (editId.value) {
|
||||||
await txStore.updateTransaction(editId.value, data)
|
await txStore.updateTransaction(editId.value, data)
|
||||||
@@ -274,6 +320,7 @@ function onContinue() {
|
|||||||
// 重置表单,保留分类和日期
|
// 重置表单,保留分类和日期
|
||||||
amountStr.value = ''
|
amountStr.value = ''
|
||||||
note.value = ''
|
note.value = ''
|
||||||
|
selectedTagIds.value = []
|
||||||
showSuccess.value = false
|
showSuccess.value = false
|
||||||
nextTick(() => amountEditorRef.value?.focus())
|
nextTick(() => amountEditorRef.value?.focus())
|
||||||
}
|
}
|
||||||
@@ -283,6 +330,28 @@ function onBack() {
|
|||||||
uni.navigateBack()
|
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() {
|
function goBack() {
|
||||||
// 保存成功后直接返回,不弹确认框
|
// 保存成功后直接返回,不弹确认框
|
||||||
if (showSuccess.value) return
|
if (showSuccess.value) return
|
||||||
@@ -310,46 +379,9 @@ function goBack() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
// AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
|
// AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
|
||||||
// 同时留出保存按钮的位置,确保内容不被遮挡
|
|
||||||
$numpad-h: 464rpx;
|
$numpad-h: 464rpx;
|
||||||
$save-bar-h: 144rpx;
|
padding-bottom: calc(#{$numpad-h} + constant(safe-area-inset-bottom));
|
||||||
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + constant(safe-area-inset-bottom));
|
padding-bottom: calc(#{$numpad-h} + env(safe-area-inset-bottom));
|
||||||
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 底部保存按钮栏
|
|
||||||
.save-bar {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: $space-md $space-xl;
|
|
||||||
padding-bottom: calc(#{$space-md} + constant(safe-area-inset-bottom));
|
|
||||||
padding-bottom: calc(#{$space-md} + env(safe-area-inset-bottom));
|
|
||||||
background: $surface;
|
|
||||||
border-top: 2rpx solid $border;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn {
|
|
||||||
height: 96rpx;
|
|
||||||
background: linear-gradient(135deg, $primary, #E67355);
|
|
||||||
border-radius: $radius-xl;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
opacity: 0.8;
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn-text {
|
|
||||||
font-size: $font-xl;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $surface;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
@@ -374,7 +406,11 @@ function goBack() {
|
|||||||
|
|
||||||
.title { font-size: $font-xl; font-weight: 500; color: $text; }
|
.title { font-size: $font-xl; font-weight: 500; color: $text; }
|
||||||
|
|
||||||
.type-toggle-wrap { display: flex; justify-content: center; margin: $space-sm 0; }
|
.type-toggle-wrap {
|
||||||
|
display: flex; justify-content: center;
|
||||||
|
margin: $space-sm 0;
|
||||||
|
@include fade-in-up;
|
||||||
|
}
|
||||||
|
|
||||||
.type-toggle {
|
.type-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -409,7 +445,7 @@ function goBack() {
|
|||||||
background: $surface;
|
background: $surface;
|
||||||
border-radius: $radius-lg;
|
border-radius: $radius-lg;
|
||||||
box-shadow: $shadow-sm;
|
box-shadow: $shadow-sm;
|
||||||
transition: transform $transition-normal;
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.income .slider { transform: translateX(160rpx); }
|
.income .slider { transform: translateX(160rpx); }
|
||||||
@@ -433,8 +469,9 @@ function goBack() {
|
|||||||
gap: 6rpx;
|
gap: 6rpx;
|
||||||
padding: 12rpx 0;
|
padding: 12rpx 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
&:active { transform: scale(0.95); }
|
&:active { transform: scale(0.92); }
|
||||||
|
|
||||||
&.suggested {
|
&.suggested {
|
||||||
.cat-icon-wrap {
|
.cat-icon-wrap {
|
||||||
@@ -442,10 +479,16 @@ function goBack() {
|
|||||||
border-radius: $radius-lg;
|
border-radius: $radius-lg;
|
||||||
padding: 6rpx;
|
padding: 6rpx;
|
||||||
margin: -6rpx;
|
margin: -6rpx;
|
||||||
|
animation: suggest-pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes suggest-pulse {
|
||||||
|
0%, 100% { border-color: $primary; }
|
||||||
|
50% { border-color: lighten($primary, 30%); }
|
||||||
|
}
|
||||||
|
|
||||||
.cat-icon-wrap {
|
.cat-icon-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all $transition-normal;
|
transition: all $transition-normal;
|
||||||
@@ -496,6 +539,99 @@ function goBack() {
|
|||||||
|
|
||||||
.placeholder { color: $text-muted; }
|
.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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -59,6 +59,29 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 健康状态 -->
|
||||||
|
<view class="health-card" v-if="health">
|
||||||
|
<view class="health-header">
|
||||||
|
<text class="health-title">系统状态</text>
|
||||||
|
<view class="health-badge" :class="health.status">
|
||||||
|
<view class="health-dot"></view>
|
||||||
|
<text class="health-badge-text">{{ health.status === 'ok' ? '正常' : health.status === 'degraded' ? '降级' : '异常' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="detail-label">数据库</text>
|
||||||
|
<text class="detail-value" :class="health.db === 'connected' ? 'income' : 'expense'">{{ health.db === 'connected' ? '已连接' : '断开' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="detail-label">运行时间</text>
|
||||||
|
<text class="detail-value">{{ formatUptime(health.uptime) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="detail-row">
|
||||||
|
<text class="detail-label">版本</text>
|
||||||
|
<text class="detail-value">{{ health.version }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 功能入口 -->
|
<!-- 功能入口 -->
|
||||||
<view class="section-title">管理功能</view>
|
<view class="section-title">管理功能</view>
|
||||||
<view class="entry-list">
|
<view class="entry-list">
|
||||||
@@ -103,12 +126,15 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
|||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight } from '@/utils/system'
|
||||||
import { getDashboard } from '@/api/admin'
|
import { getDashboard } from '@/api/admin'
|
||||||
|
import { getHealth } from '@/api/health'
|
||||||
|
import type { HealthStatus } from '@/api/health'
|
||||||
import { formatAmount } from '@/utils/format'
|
import { formatAmount } from '@/utils/format'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import type { Dashboard } from '@/api/admin'
|
import type { Dashboard } from '@/api/admin'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const dashboard = ref<Dashboard | null>(null)
|
const dashboard = ref<Dashboard | null>(null)
|
||||||
|
const health = ref<HealthStatus | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
@@ -120,12 +146,20 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载健康状态(不阻塞页面)
|
||||||
|
try {
|
||||||
|
health.value = await getHealth()
|
||||||
|
} catch {
|
||||||
|
// 健康检查失败不影响主流程
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 静默刷新
|
// 静默刷新
|
||||||
async function refreshDashboard() {
|
async function refreshDashboard() {
|
||||||
try {
|
try {
|
||||||
dashboard.value = await getDashboard()
|
dashboard.value = await getDashboard()
|
||||||
|
health.value = await getHealth()
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +180,16 @@ function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) }
|
|||||||
function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
|
function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
|
||||||
function goLogs() { uni.navigateTo({ url: '/pages/admin/logs' }) }
|
function goLogs() { uni.navigateTo({ url: '/pages/admin/logs' }) }
|
||||||
function goTrack() { uni.navigateTo({ url: '/pages/admin/track' }) }
|
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}分钟`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -299,4 +343,58 @@ function goTrack() { uni.navigateTo({ url: '/pages/admin/track' }) }
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: $text;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 健康状态卡片 */
|
||||||
|
.health-card {
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
padding: $space-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
margin-bottom: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-xs;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
|
||||||
|
&.ok { background: rgba(123, 198, 126, 0.1); }
|
||||||
|
&.degraded { background: rgba(255, 215, 0, 0.1); }
|
||||||
|
&.error { background: rgba(255, 107, 107, 0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-dot {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
.ok & { background: $success; }
|
||||||
|
.degraded & { background: #FFD700; }
|
||||||
|
.error & { background: $danger; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-badge-text {
|
||||||
|
font-size: $font-sm;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.ok & { color: $success; }
|
||||||
|
.degraded & { color: #CC9900; }
|
||||||
|
.error & { color: $danger; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
301
client/src/pages/backup-manage/index.vue
Normal file
301
client/src/pages/backup-manage/index.vue
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="header-fixed">
|
||||||
|
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||||
|
<view class="nav-bar">
|
||||||
|
<view class="nav-back" @tap="goBack">
|
||||||
|
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
|
||||||
|
</view>
|
||||||
|
<text class="nav-title">数据备份</text>
|
||||||
|
<view class="nav-placeholder"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="action-bar">
|
||||||
|
<view class="create-btn" :class="{ disabled: creating }" @tap="onCreate">
|
||||||
|
<Icon name="plus" :size="28" color="#FFFFFF" />
|
||||||
|
<text class="create-text">{{ creating ? '创建中...' : '创建备份' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="backup-list" v-if="backups.length > 0">
|
||||||
|
<view v-for="item in backups" :key="item.id" class="backup-item">
|
||||||
|
<view class="bk-info">
|
||||||
|
<view class="bk-name-row">
|
||||||
|
<Icon name="archive" :size="28" :color="item.status === 'completed' ? '#7BC67E' : '#BFB3B3'" />
|
||||||
|
<text class="bk-name">{{ item.name }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="bk-meta">{{ formatDate(item.created_at) }} · {{ formatSize(item.size) }}</text>
|
||||||
|
<text class="bk-status" :class="item.status">
|
||||||
|
{{ item.status === 'completed' ? '已完成' : item.status === 'pending' ? '处理中' : '失败' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="bk-actions" v-if="item.status === 'completed'">
|
||||||
|
<view class="bk-btn download" @tap="onDownload(item)">
|
||||||
|
<Icon name="download" :size="24" color="#5B9BD5" />
|
||||||
|
</view>
|
||||||
|
<view class="bk-btn delete" @tap="onDelete(item)">
|
||||||
|
<Icon name="trash" :size="24" color="#FF6B6B" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="empty" v-else-if="!loading">
|
||||||
|
<Icon name="archive" :size="48" color="#BFB3B3" />
|
||||||
|
<text class="empty-text">暂无备份记录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="loading-box" v-if="loading">
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
|
import { getBackups, createBackup, deleteBackup } from '@/api/backup'
|
||||||
|
import type { Backup } from '@/api/backup'
|
||||||
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
|
import { statusBarHeight } from '@/utils/system'
|
||||||
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
|
||||||
|
const backups = ref<Backup[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const creating = ref(false)
|
||||||
|
const initialLoaded = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await waitForReady()
|
||||||
|
await loadBackups()
|
||||||
|
initialLoaded.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (initialLoaded.value) loadBackups(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await loadBackups(true)
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadBackups(silent = false) {
|
||||||
|
if (!silent) loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await getBackups({ page: 1, pageSize: 50 })
|
||||||
|
backups.value = data.list || []
|
||||||
|
} catch {
|
||||||
|
backups.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreate() {
|
||||||
|
if (creating.value) return
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
await createBackup()
|
||||||
|
uni.showToast({ title: '备份创建成功', icon: 'success' })
|
||||||
|
await loadBackups()
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '创建失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDownload(item: Backup) {
|
||||||
|
if (!item.downloadToken) {
|
||||||
|
uni.showToast({ title: '下载链接无效', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 在新窗口打开下载链接(H5)
|
||||||
|
// #ifdef H5
|
||||||
|
const apiBase = '/api'
|
||||||
|
const url = `${apiBase}/backup/${item.id}/download?token=${encodeURIComponent(item.downloadToken)}`
|
||||||
|
window.open(url, '_blank')
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
uni.downloadFile({
|
||||||
|
url: `https://xiaocai.j35.site/api/backup/${item.id}/download?token=${encodeURIComponent(item.downloadToken)}`,
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
uni.openDocument({
|
||||||
|
filePath: res.tempFilePath,
|
||||||
|
showMenu: true,
|
||||||
|
fail: () => uni.showToast({ title: '打开失败', icon: 'none' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => uni.showToast({ title: '下载失败', icon: 'none' })
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(item: Backup) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除备份',
|
||||||
|
content: `确定删除备份「${item.name}」?`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await deleteBackup(item.id)
|
||||||
|
backups.value = backups.value.filter(b => b.id !== item.id)
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (!bytes || bytes === 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let i = 0
|
||||||
|
let size = bytes
|
||||||
|
while (size >= 1024 && i < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[i]}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
@include page-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fixed { @include sticky-header; }
|
||||||
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
@include nav-back;
|
||||||
|
border-radius: 50%;
|
||||||
|
&:active { background: $border; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder { width: 72rpx; }
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
padding: $space-lg $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn {
|
||||||
|
@include btn-primary;
|
||||||
|
gap: $space-sm;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.disabled { opacity: 0.5; pointer-events: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-text { @include btn-primary-text; }
|
||||||
|
|
||||||
|
.backup-list { padding: 0 $space-xl; }
|
||||||
|
|
||||||
|
.backup-item {
|
||||||
|
@include card($radius-xl);
|
||||||
|
padding: $space-lg;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-name {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-meta {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-status {
|
||||||
|
font-size: $font-xs;
|
||||||
|
padding: 2rpx 12rpx;
|
||||||
|
border-radius: $radius-xs;
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
&.completed { color: $success; background: rgba(123, 198, 126, 0.1); }
|
||||||
|
&.pending { color: $primary; background: $primary-light; }
|
||||||
|
&.failed { color: $danger; background: rgba(255, 107, 107, 0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bk-btn {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $bg;
|
||||||
|
&:active { background: $border; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
@include state-box;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text { @include state-text; }
|
||||||
|
|
||||||
|
.loading-box {
|
||||||
|
@include flex-center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text { font-size: $font-lg; color: $text-muted; }
|
||||||
|
</style>
|
||||||
@@ -17,6 +17,25 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 标签筛选条 -->
|
||||||
|
<scroll-view class="tag-filter-scroll" scroll-x v-if="tagStore.tags.length > 0">
|
||||||
|
<view class="tag-filter-row">
|
||||||
|
<view class="tag-filter-chip" :class="{ active: filterTagId === null }" @tap="filterTagId = null; loadTx(true)">
|
||||||
|
<text class="tag-filter-text">全部标签</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="tag in tagStore.tags"
|
||||||
|
:key="tag.id"
|
||||||
|
class="tag-filter-chip"
|
||||||
|
:class="{ active: filterTagId === tag.id }"
|
||||||
|
@tap="filterTagId = tag.id; loadTx(true)"
|
||||||
|
>
|
||||||
|
<view class="tag-filter-dot" :style="{ background: tag.color }"></view>
|
||||||
|
<text class="tag-filter-text">{{ tag.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 筛选结果统计 -->
|
<!-- 筛选结果统计 -->
|
||||||
<view class="filter-stats" v-if="hasAdvancedFilter && !loading">
|
<view class="filter-stats" v-if="hasAdvancedFilter && !loading">
|
||||||
<text class="filter-stats-text">共 {{ total }} 笔</text>
|
<text class="filter-stats-text">共 {{ total }} 笔</text>
|
||||||
@@ -43,6 +62,8 @@
|
|||||||
:hideDate="true"
|
:hideDate="true"
|
||||||
:showCreator="groupStore.isGroupMode"
|
:showCreator="groupStore.isGroupMode"
|
||||||
:currentUserId="userStore.userInfo?.id"
|
:currentUserId="userStore.userInfo?.id"
|
||||||
|
:disableSwipe="groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id"
|
||||||
|
:swipeHint="!swipeHintDismissed && isFirstDeletable(item)"
|
||||||
@item-tap="onEdit(item)"
|
@item-tap="onEdit(item)"
|
||||||
@delete="onDelete(item)"
|
@delete="onDelete(item)"
|
||||||
/>
|
/>
|
||||||
@@ -91,6 +112,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { useTagStore } from '@/stores/tag'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { getTransactions, deleteTransaction } from '@/api/transaction'
|
import { getTransactions, deleteTransaction } from '@/api/transaction'
|
||||||
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
|
||||||
@@ -104,8 +126,10 @@ import type { FilterParams } from '@/api/filter'
|
|||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
const tagStore = useTagStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const filterType = ref('all')
|
const filterType = ref('all')
|
||||||
|
const filterTagId = ref<number | null>(null)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
const txList = ref<Transaction[]>([])
|
const txList = ref<Transaction[]>([])
|
||||||
@@ -113,6 +137,35 @@ const total = ref(0)
|
|||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
let loadSeq = 0 // 请求序号,防止并发覆盖
|
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 showFilter = ref(false)
|
||||||
const advancedFilters = ref<FilterParams>({})
|
const advancedFilters = ref<FilterParams>({})
|
||||||
@@ -145,6 +198,7 @@ const initialLoaded = ref(false)
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
|
tagStore.fetchTags().catch(() => {})
|
||||||
loadTx(true).finally(() => { initialLoaded.value = true })
|
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.minAmount) params.minAmount = af.minAmount
|
||||||
if (af.maxAmount) params.maxAmount = af.maxAmount
|
if (af.maxAmount) params.maxAmount = af.maxAmount
|
||||||
if (af.keyword) params.keyword = af.keyword
|
if (af.keyword) params.keyword = af.keyword
|
||||||
|
if (filterTagId.value) params.tagId = filterTagId.value
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = false
|
loadError.value = false
|
||||||
@@ -379,4 +434,51 @@ onPullDownRefresh(() => {
|
|||||||
.state-text {
|
.state-text {
|
||||||
@include 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -67,18 +67,9 @@
|
|||||||
size="large"
|
size="large"
|
||||||
unit="元"
|
unit="元"
|
||||||
:auto-focus="false"
|
:auto-focus="false"
|
||||||
@focus="keyboardVisible = true"
|
|
||||||
@blur="keyboardVisible = false"
|
|
||||||
@confirm="onConfirm"
|
@confirm="onConfirm"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 底部保存按钮(键盘收起时显示) -->
|
|
||||||
<view class="save-bar" v-if="!keyboardVisible">
|
|
||||||
<view class="save-btn" @tap="onConfirm">
|
|
||||||
<text class="save-btn-text">保存预算</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -107,7 +98,6 @@ const loading = ref(true)
|
|||||||
const initialLoaded = ref(false)
|
const initialLoaded = ref(false)
|
||||||
const amountStr = ref('')
|
const amountStr = ref('')
|
||||||
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
|
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
|
||||||
const keyboardVisible = ref(false)
|
|
||||||
|
|
||||||
const presets = [1000, 2000, 3000, 5000, 10000]
|
const presets = [1000, 2000, 3000, 5000, 10000]
|
||||||
|
|
||||||
@@ -207,11 +197,10 @@ function goBack() {
|
|||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: $bg;
|
background: $bg;
|
||||||
// AmountEditor fixed Numpad 占位 + 保存按钮占位
|
// AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
|
||||||
$numpad-h: 464rpx;
|
$numpad-h: 464rpx;
|
||||||
$save-bar-h: 144rpx;
|
padding-bottom: calc(#{$numpad-h} + constant(safe-area-inset-bottom));
|
||||||
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + constant(safe-area-inset-bottom));
|
padding-bottom: calc(#{$numpad-h} + env(safe-area-inset-bottom));
|
||||||
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + env(safe-area-inset-bottom));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
@@ -383,40 +372,6 @@ function goBack() {
|
|||||||
color: $text;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 底部保存按钮栏
|
|
||||||
.save-bar {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: $space-md $space-xl;
|
|
||||||
padding-bottom: calc(#{$space-md} + constant(safe-area-inset-bottom));
|
|
||||||
padding-bottom: calc(#{$space-md} + env(safe-area-inset-bottom));
|
|
||||||
background: $surface;
|
|
||||||
border-top: 2rpx solid $border;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn {
|
|
||||||
height: 96rpx;
|
|
||||||
background: linear-gradient(135deg, $primary, #E67355);
|
|
||||||
border-radius: $radius-xl;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
opacity: 0.8;
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-btn-text {
|
|
||||||
font-size: $font-xl;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $surface;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="cat-list">
|
<view class="cat-list">
|
||||||
<view v-for="cat in currentCategories" :key="cat.id" class="cat-row">
|
<view v-for="cat in currentCategories" :key="cat.id" class="cat-row" :data-id="cat.id">
|
||||||
|
<view class="cat-drag-handle" @touchstart="onDragStart" @touchmove="onDragMove" @touchend="onDragEnd" v-if="tabType === 'expense' || tabType === 'income'">
|
||||||
|
<Icon name="dragHandle" :size="24" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
<view class="cat-info" @tap="cat.is_custom && onEdit(cat)">
|
<view class="cat-info" @tap="cat.is_custom && onEdit(cat)">
|
||||||
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" />
|
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" />
|
||||||
<text class="cat-name">{{ cat.name }}</text>
|
<text class="cat-name">{{ cat.name }}</text>
|
||||||
@@ -126,6 +129,11 @@ const newName = ref('')
|
|||||||
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
|
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
|
||||||
const selectedColor = ref(colorOptions[0])
|
const selectedColor = ref(colorOptions[0])
|
||||||
|
|
||||||
|
// 拖拽排序相关
|
||||||
|
let dragStartY = 0
|
||||||
|
let dragCurrentIndex = -1
|
||||||
|
let dragItemEl: any = null
|
||||||
|
|
||||||
// 编辑相关
|
// 编辑相关
|
||||||
const showEditModal = ref(false)
|
const showEditModal = ref(false)
|
||||||
const editingCat = ref<Category | null>(null)
|
const editingCat = ref<Category | null>(null)
|
||||||
@@ -270,6 +278,52 @@ async function onMigrateConfirm() {
|
|||||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 拖拽排序:开始 */
|
||||||
|
function onDragStart(e: any) {
|
||||||
|
dragStartY = e.touches[0].clientY
|
||||||
|
dragItemEl = e.currentTarget?.parentElement
|
||||||
|
if (dragItemEl) {
|
||||||
|
const id = Number(dragItemEl.dataset?.id)
|
||||||
|
const idx = currentCategories.value.findIndex(c => c.id === id)
|
||||||
|
dragCurrentIndex = idx
|
||||||
|
dragItemEl.style?.opacity && (dragItemEl.style.opacity = '0.6')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖拽排序:移动 */
|
||||||
|
function onDragMove(e: any) {
|
||||||
|
// 视觉反馈由 CSS transition 处理,这里仅用于阻止页面滚动
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖拽排序:结束 */
|
||||||
|
function onDragEnd(e: any) {
|
||||||
|
if (dragItemEl) {
|
||||||
|
dragItemEl.style?.opacity && (dragItemEl.style.opacity = '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
const endY = e.changedTouches[0].clientY
|
||||||
|
const delta = endY - dragStartY
|
||||||
|
const threshold = 50
|
||||||
|
|
||||||
|
if (Math.abs(delta) > threshold && dragCurrentIndex >= 0) {
|
||||||
|
const direction = delta > 0 ? 1 : -1
|
||||||
|
const newIndex = dragCurrentIndex + direction
|
||||||
|
const cats = [...currentCategories.value]
|
||||||
|
if (newIndex >= 0 && newIndex < cats.length) {
|
||||||
|
// 交换位置
|
||||||
|
const temp = cats[dragCurrentIndex]
|
||||||
|
cats[dragCurrentIndex] = cats[newIndex]
|
||||||
|
cats[newIndex] = temp
|
||||||
|
// 保存排序
|
||||||
|
const sortedIds = cats.map(c => c.id)
|
||||||
|
catStore.sortCategories(sortedIds).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dragCurrentIndex = -1
|
||||||
|
dragItemEl = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -337,6 +391,16 @@ async function onMigrateConfirm() {
|
|||||||
&:last-child { border-bottom: none; }
|
&:last-child { border-bottom: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cat-drag-handle {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
.cat-info {
|
.cat-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
343
client/src/pages/data-import/index.vue
Normal file
343
client/src/pages/data-import/index.vue
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="header-fixed">
|
||||||
|
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||||
|
<view class="nav-bar">
|
||||||
|
<view class="nav-back" @tap="goBack">
|
||||||
|
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
|
||||||
|
</view>
|
||||||
|
<text class="nav-title">数据导入</text>
|
||||||
|
<view class="nav-placeholder"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="content">
|
||||||
|
<view class="section">
|
||||||
|
<text class="section-title">选择文件</text>
|
||||||
|
<text class="section-desc">支持 CSV、JSON 格式,文件需小于 5MB</text>
|
||||||
|
<view class="file-picker" @tap="onPickFile">
|
||||||
|
<Icon name="upload" :size="48" color="#FF8C69" />
|
||||||
|
<text class="file-picker-text">{{ fileName || '点击选择文件' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section" v-if="fileName">
|
||||||
|
<text class="section-title">文件预览</text>
|
||||||
|
<text class="preview-info">格式:{{ fileFormat?.toUpperCase() }} · 大小:{{ fileSize }}</text>
|
||||||
|
<text class="preview-info">识别到 {{ parsedCount }} 条记录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section" v-if="importResult">
|
||||||
|
<text class="section-title">导入结果</text>
|
||||||
|
<view class="result-card">
|
||||||
|
<view class="result-row">
|
||||||
|
<text class="result-label">成功导入</text>
|
||||||
|
<text class="result-value success">{{ importResult.imported }} 条</text>
|
||||||
|
</view>
|
||||||
|
<view class="result-row">
|
||||||
|
<text class="result-label">跳过</text>
|
||||||
|
<text class="result-value warn">{{ importResult.skipped }} 条</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="action-area" v-if="fileName && !importResult">
|
||||||
|
<view class="import-btn" :class="{ disabled: importing || parsedCount === 0 }" @tap="onImport">
|
||||||
|
<text class="import-text">{{ importing ? '导入中...' : '开始导入' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { statusBarHeight } from '@/utils/system'
|
||||||
|
import { importTransactions } from '@/api/transaction'
|
||||||
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
|
||||||
|
const fileName = ref('')
|
||||||
|
const fileSize = ref('')
|
||||||
|
const fileFormat = ref<'csv' | 'json' | null>(null)
|
||||||
|
const parsedCount = ref(0)
|
||||||
|
const parsedData = ref<Array<{
|
||||||
|
date: string
|
||||||
|
type: 'expense' | 'income'
|
||||||
|
category_name: string
|
||||||
|
amount: number
|
||||||
|
note?: string
|
||||||
|
}>>([])
|
||||||
|
const importing = ref(false)
|
||||||
|
const importResult = ref<{ imported: number; skipped: number } | null>(null)
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickFile() {
|
||||||
|
// #ifdef H5
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = '.csv,.json'
|
||||||
|
input.onchange = (e: Event) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
handleFile(file)
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
uni.chooseMessageFile({
|
||||||
|
count: 1,
|
||||||
|
type: 'file',
|
||||||
|
extension: ['.csv', '.json'],
|
||||||
|
success: (res) => {
|
||||||
|
const file = res.tempFiles[0]
|
||||||
|
fileName.value = file.name
|
||||||
|
fileSize.value = formatFileSize(file.size)
|
||||||
|
fileFormat.value = file.name.endsWith('.json') ? 'json' : 'csv'
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
const fs = uni.getFileSystemManager()
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(file.path, 'utf-8') as string
|
||||||
|
parseContent(content, fileFormat.value!)
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '文件读取失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFile(file: File) {
|
||||||
|
fileName.value = file.name
|
||||||
|
fileSize.value = formatFileSize(file.size)
|
||||||
|
fileFormat.value = file.name.endsWith('.json') ? 'json' : 'csv'
|
||||||
|
|
||||||
|
// 检查大小
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
uni.showToast({ title: '文件不能超过 5MB', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const content = e.target?.result as string
|
||||||
|
parseContent(content, fileFormat.value!)
|
||||||
|
}
|
||||||
|
reader.readAsText(file, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContent(content: string, format: 'csv' | 'json') {
|
||||||
|
try {
|
||||||
|
importResult.value = null
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
const items = JSON.parse(content)
|
||||||
|
if (!Array.isArray(items)) {
|
||||||
|
uni.showToast({ title: 'JSON 需为数组格式', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parsedData.value = items.map((item: any) => ({
|
||||||
|
date: item.date || item.日期 || '',
|
||||||
|
type: (item.type === '收入' || item.type === 'income') ? 'income' as const : 'expense' as const,
|
||||||
|
category_name: item.category || item.category_name || item.分类 || '未分类',
|
||||||
|
amount: Math.round((Number(item.amount || item.金额 || 0)) * 100),
|
||||||
|
note: item.note || item.备注 || ''
|
||||||
|
})).filter((item: any) => item.date && item.amount > 0)
|
||||||
|
} else {
|
||||||
|
// CSV 解析
|
||||||
|
const lines = content.replace(/^\uFEFF/, '').split('\n').filter(line => line.trim())
|
||||||
|
if (lines.length < 2) {
|
||||||
|
uni.showToast({ title: 'CSV 文件为空', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 跳过表头
|
||||||
|
parsedData.value = lines.slice(1).map(line => {
|
||||||
|
const parts = parseCsvLine(line)
|
||||||
|
return {
|
||||||
|
date: (parts[0] || '').trim(),
|
||||||
|
type: (parts[1] || '').includes('收入') ? 'income' as const : 'expense' as const,
|
||||||
|
category_name: (parts[2] || '未分类').trim(),
|
||||||
|
amount: Math.round(parseFloat((parts[3] || '0').replace(/,/g, '')) * 100),
|
||||||
|
note: (parts[4] || '').trim()
|
||||||
|
}
|
||||||
|
}).filter(item => item.date && item.amount > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedCount.value = parsedData.value.length
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '文件解析失败', icon: 'none' })
|
||||||
|
parsedData.value = []
|
||||||
|
parsedCount.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCsvLine(line: string): string[] {
|
||||||
|
const result: string[] = []
|
||||||
|
let current = ''
|
||||||
|
let inQuotes = false
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i]
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
current += '"'
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
result.push(current)
|
||||||
|
current = ''
|
||||||
|
} else {
|
||||||
|
current += char
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(current)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onImport() {
|
||||||
|
if (importing.value || parsedCount.value === 0) return
|
||||||
|
|
||||||
|
importing.value = true
|
||||||
|
try {
|
||||||
|
const result = await importTransactions({ transactions: parsedData.value })
|
||||||
|
importResult.value = result
|
||||||
|
uni.showToast({ title: '导入完成', icon: 'success' })
|
||||||
|
} catch (e: any) {
|
||||||
|
uni.showToast({ title: e.message || '导入失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let i = 0
|
||||||
|
let size = bytes
|
||||||
|
while (size >= 1024 && i < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[i]}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
@include page-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fixed { @include sticky-header; }
|
||||||
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
@include nav-back;
|
||||||
|
border-radius: 50%;
|
||||||
|
&:active { background: $border; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder { width: 72rpx; }
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: $space-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-muted;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-picker {
|
||||||
|
@include card($radius-2xl);
|
||||||
|
padding: $space-2xl;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-md;
|
||||||
|
&:active { background: $surface-warm; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-picker-text {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text-sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
@include card($radius-xl);
|
||||||
|
padding: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm 0;
|
||||||
|
border-bottom: 2rpx solid $border;
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-label { font-size: $font-lg; color: $text-sec; }
|
||||||
|
|
||||||
|
.result-value {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.success { color: $success; }
|
||||||
|
&.warn { color: $primary; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-area {
|
||||||
|
padding-top: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-btn {
|
||||||
|
@include btn-primary;
|
||||||
|
width: 100%;
|
||||||
|
&.disabled { opacity: 0.5; pointer-events: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-text { @include btn-primary-text; }
|
||||||
|
</style>
|
||||||
@@ -11,7 +11,15 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="content">
|
<view class="tabs-wrap">
|
||||||
|
<view class="tabs">
|
||||||
|
<view class="tab" :class="{ active: currentTab === 'submit' }" @tap="currentTab = 'submit'">提交反馈</view>
|
||||||
|
<view class="tab" :class="{ active: currentTab === 'mine' }" @tap="currentTab = 'mine'">我的反馈</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 提交反馈 Tab -->
|
||||||
|
<view class="content" v-if="currentTab === 'submit'">
|
||||||
<!-- 反馈类型 -->
|
<!-- 反馈类型 -->
|
||||||
<view class="section">
|
<view class="section">
|
||||||
<text class="label">反馈类型</text>
|
<text class="label">反馈类型</text>
|
||||||
@@ -59,13 +67,45 @@
|
|||||||
|
|
||||||
<text class="tip">感谢您的反馈,我们会认真处理每一条意见</text>
|
<text class="tip">感谢您的反馈,我们会认真处理每一条意见</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 我的反馈 Tab -->
|
||||||
|
<view class="mine-content" v-if="currentTab === 'mine'">
|
||||||
|
<view class="feedback-list" v-if="myFeedbacks.length > 0">
|
||||||
|
<view v-for="item in myFeedbacks" :key="item.id" class="fb-item">
|
||||||
|
<view class="fb-header">
|
||||||
|
<text class="fb-type">{{ getTypeLabel(item.type) }}</text>
|
||||||
|
<text class="fb-status" :class="item.status">{{ getStatusText(item.status) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="fb-content">{{ item.content }}</text>
|
||||||
|
<text class="fb-date">{{ formatDateStr(item.created_at) }}</text>
|
||||||
|
<view class="fb-reply" v-if="item.admin_reply">
|
||||||
|
<text class="fb-reply-label">管理员回复:</text>
|
||||||
|
<text class="fb-reply-text">{{ item.admin_reply }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="empty" v-else-if="!mineLoading">
|
||||||
|
<Icon name="edit" :size="48" color="#BFB3B3" />
|
||||||
|
<text class="empty-text">暂无反馈记录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="loading-box" v-if="mineLoading">
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="load-more" v-if="!mineLoading && myFeedbacks.length > 0 && !mineNoMore" @tap="loadMoreFeedbacks">
|
||||||
|
<text class="load-more-text">加载更多</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
import { statusBarHeight } from '@/utils/system'
|
import { statusBarHeight } from '@/utils/system'
|
||||||
import { submitFeedback } from '@/api/feedback'
|
import { submitFeedback, getMyFeedbacks } from '@/api/feedback'
|
||||||
|
import type { MyFeedback } from '@/api/feedback'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
|
||||||
const types = [
|
const types = [
|
||||||
@@ -75,6 +115,7 @@ const types = [
|
|||||||
{ label: '其他', value: 'other' }
|
{ label: '其他', value: 'other' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const currentTab = ref<'submit' | 'mine'>('submit')
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
type: 'bug',
|
type: 'bug',
|
||||||
content: '',
|
content: '',
|
||||||
@@ -83,6 +124,50 @@ const form = reactive({
|
|||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
// 我的反馈列表
|
||||||
|
const myFeedbacks = ref<MyFeedback[]>([])
|
||||||
|
const mineLoading = ref(false)
|
||||||
|
const minePage = ref(1)
|
||||||
|
const mineTotal = ref(0)
|
||||||
|
const mineNoMore = ref(false)
|
||||||
|
|
||||||
|
// 切换到我的反馈 tab 时加载数据
|
||||||
|
watch(currentTab, (tab) => {
|
||||||
|
if (tab === 'mine' && myFeedbacks.value.length === 0) {
|
||||||
|
loadMyFeedbacks(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadMyFeedbacks(reset = false) {
|
||||||
|
if (mineLoading.value) return
|
||||||
|
if (reset) {
|
||||||
|
minePage.value = 1
|
||||||
|
myFeedbacks.value = []
|
||||||
|
mineNoMore.value = false
|
||||||
|
}
|
||||||
|
mineLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await getMyFeedbacks({ page: minePage.value, pageSize: 20 })
|
||||||
|
const list = data.list || []
|
||||||
|
if (reset) {
|
||||||
|
myFeedbacks.value = list
|
||||||
|
} else {
|
||||||
|
myFeedbacks.value = [...myFeedbacks.value, ...list]
|
||||||
|
}
|
||||||
|
mineTotal.value = data.total
|
||||||
|
mineNoMore.value = myFeedbacks.value.length >= data.total
|
||||||
|
} catch {
|
||||||
|
// 静默处理
|
||||||
|
} finally {
|
||||||
|
mineLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreFeedbacks() {
|
||||||
|
minePage.value++
|
||||||
|
loadMyFeedbacks()
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
if (submitting.value || !form.content.trim()) return
|
if (submitting.value || !form.content.trim()) return
|
||||||
|
|
||||||
@@ -94,7 +179,12 @@ async function onSubmit() {
|
|||||||
contact: form.contact.trim() || undefined
|
contact: form.contact.trim() || undefined
|
||||||
})
|
})
|
||||||
uni.showToast({ title: '提交成功', icon: 'success' })
|
uni.showToast({ title: '提交成功', icon: 'success' })
|
||||||
setTimeout(() => uni.navigateBack(), 1500)
|
form.content = ''
|
||||||
|
form.contact = ''
|
||||||
|
// 刷新我的反馈列表
|
||||||
|
if (currentTab.value === 'mine') {
|
||||||
|
loadMyFeedbacks(true)
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
|
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -105,6 +195,22 @@ async function onSubmit() {
|
|||||||
function goBack() {
|
function goBack() {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTypeLabel(type: string): string {
|
||||||
|
const map: Record<string, string> = { bug: '功能异常', feature: '功能建议', ux: '体验问题', other: '其他' }
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(status: string): string {
|
||||||
|
const map: Record<string, string> = { pending: '待处理', processed: '已处理', ignored: '已忽略' }
|
||||||
|
return map[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateStr(dateStr: string): string {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -259,4 +365,121 @@ function goBack() {
|
|||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
margin-top: $space-lg;
|
margin-top: $space-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs-wrap {
|
||||||
|
@include tabs-wrap;
|
||||||
|
margin: $space-sm 0 $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
@include tabs;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
@include tab;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 我的反馈 */
|
||||||
|
.mine-content {
|
||||||
|
padding: 0 $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-item {
|
||||||
|
@include card($radius-xl);
|
||||||
|
padding: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-type {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-status {
|
||||||
|
font-size: $font-sm;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: $radius-xs;
|
||||||
|
|
||||||
|
&.pending { color: $primary; background: $primary-light; }
|
||||||
|
&.processed { color: $success; background: rgba(123, 198, 126, 0.1); }
|
||||||
|
&.ignored { color: $text-muted; background: $bg; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-content {
|
||||||
|
font-size: $font-lg;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-date {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-muted;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-reply {
|
||||||
|
margin-top: $space-sm;
|
||||||
|
padding: $space-md;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border-left: 6rpx solid $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-reply-label {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $success;
|
||||||
|
font-weight: 500;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fb-reply-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
@include state-box;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text { @include state-text; }
|
||||||
|
|
||||||
|
.loading-box {
|
||||||
|
@include flex-center;
|
||||||
|
padding: 80rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text { font-size: $font-lg; color: $text-muted; }
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: $space-lg 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-text { font-size: $font-md; color: $primary; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -45,13 +45,22 @@
|
|||||||
<text class="section-title">我的群组</text>
|
<text class="section-title">我的群组</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="groupStore.loading" class="loading-box">
|
<view v-if="groupStore.loading" class="skeleton-list">
|
||||||
<text class="loading-text">加载中...</text>
|
<view v-for="i in 2" :key="i" class="skeleton-card">
|
||||||
|
<Skeleton width="80rpx" height="80rpx" variant="circle" />
|
||||||
|
<view class="skeleton-info">
|
||||||
|
<Skeleton width="200rpx" height="28rpx" variant="text" />
|
||||||
|
<Skeleton width="280rpx" height="24rpx" variant="text" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else-if="groupStore.groups.length === 0" class="empty-box">
|
<view v-else-if="groupStore.groups.length === 0" class="empty-box">
|
||||||
|
<view class="empty-icon-wrap">
|
||||||
<Icon name="user" :size="48" color="#BFB3B3" />
|
<Icon name="user" :size="48" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
<text class="empty-text">还没有加入任何群组</text>
|
<text class="empty-text">还没有加入任何群组</text>
|
||||||
|
<text class="empty-hint">创建或加入一个群组,和家人朋友一起记账</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="group-list">
|
<view v-else class="group-list">
|
||||||
@@ -147,6 +156,7 @@ import { waitForReady } from '@/utils/app-ready'
|
|||||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||||
import { API_BASE } from '@/config'
|
import { API_BASE } from '@/config'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||||
import type { GroupMember, Group } from '@/api/group'
|
import type { GroupMember, Group } from '@/api/group'
|
||||||
|
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
@@ -473,7 +483,20 @@ function handleDissolve(group: Group) {
|
|||||||
color: $text-sec;
|
color: $text-sec;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-box, .empty-box {
|
// 骨架屏
|
||||||
|
.skeleton-list { padding: 0 $space-xl; }
|
||||||
|
.skeleton-card {
|
||||||
|
display: flex; align-items: center; gap: $space-md;
|
||||||
|
padding: $space-lg; margin-bottom: $space-sm;
|
||||||
|
background: $surface; border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
.skeleton-info {
|
||||||
|
flex: 1; display: flex; flex-direction: column; gap: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
.empty-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -482,8 +505,20 @@ function handleDissolve(group: Group) {
|
|||||||
padding: 80rpx 0;
|
padding: 80rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-text, .empty-text {
|
.empty-icon-wrap {
|
||||||
font-size: $font-lg;
|
width: 120rpx; height: 120rpx; border-radius: 50%;
|
||||||
|
background: $surface-warm; @include flex-center;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: $font-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: $font-md;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -364,6 +364,7 @@ function goBills() {
|
|||||||
padding: $space-2xl;
|
padding: $space-2xl;
|
||||||
@include card($radius-2xl);
|
@include card($radius-2xl);
|
||||||
box-shadow: $shadow-clay;
|
box-shadow: $shadow-clay;
|
||||||
|
@include fade-in-up;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-label {
|
.card-label {
|
||||||
@@ -393,6 +394,7 @@ function goBills() {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $text;
|
color: $text;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
@include count-up;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-stats {
|
.mini-stats {
|
||||||
@@ -434,6 +436,7 @@ function goBills() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $space-md;
|
gap: $space-md;
|
||||||
|
@include fade-in-up(0.1s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-item {
|
.today-item {
|
||||||
@@ -475,6 +478,7 @@ function goBills() {
|
|||||||
gap: $space-md;
|
gap: $space-md;
|
||||||
padding: 0 $space-xl;
|
padding: 0 $space-xl;
|
||||||
margin-top: $space-xl;
|
margin-top: $space-xl;
|
||||||
|
@include fade-in-up(0.15s);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-btn {
|
.quick-btn {
|
||||||
@@ -485,9 +489,10 @@ function goBills() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@include flex-center;
|
@include flex-center;
|
||||||
gap: $space-xs;
|
gap: $space-xs;
|
||||||
|
transition: transform $transition-fast, box-shadow $transition-fast;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.97);
|
transform: scale(0.94);
|
||||||
box-shadow: $shadow-clay-pressed;
|
box-shadow: $shadow-clay-pressed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,23 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 通知列表 -->
|
<!-- 通知列表 -->
|
||||||
<view v-if="loading && list.length === 0" class="loading-box">
|
<view v-if="loading && list.length === 0" class="skeleton-list">
|
||||||
<text class="loading-text">加载中...</text>
|
<view v-for="i in 4" :key="i" class="skeleton-notif">
|
||||||
|
<Skeleton width="64rpx" height="64rpx" variant="circle" />
|
||||||
|
<view class="skeleton-info">
|
||||||
|
<Skeleton width="240rpx" height="28rpx" variant="text" />
|
||||||
|
<Skeleton width="400rpx" height="24rpx" variant="text" />
|
||||||
|
<Skeleton width="120rpx" height="20rpx" variant="text" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else-if="list.length === 0" class="empty-box">
|
<view v-else-if="list.length === 0" class="empty-box">
|
||||||
|
<view class="empty-icon-wrap">
|
||||||
<Icon name="bell" :size="48" color="#BFB3B3" />
|
<Icon name="bell" :size="48" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
<text class="empty-text">暂无通知</text>
|
<text class="empty-text">暂无通知</text>
|
||||||
|
<text class="empty-hint">新的通知会在这里显示</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else class="notification-list">
|
<view v-else class="notification-list">
|
||||||
@@ -87,6 +97,7 @@ import { getNotifications, markRead, markAllRead, getNotificationImageUrl } from
|
|||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||||
import type { Notification } from '@/api/notification'
|
import type { Notification } from '@/api/notification'
|
||||||
|
|
||||||
const notifStore = useNotificationStore()
|
const notifStore = useNotificationStore()
|
||||||
@@ -305,7 +316,20 @@ onReachBottom(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-box, .empty-box {
|
// 骨架屏
|
||||||
|
.skeleton-list { padding: 0 $space-xl; }
|
||||||
|
.skeleton-notif {
|
||||||
|
display: flex; align-items: flex-start; gap: $space-lg;
|
||||||
|
padding: $space-lg; margin-bottom: $space-sm;
|
||||||
|
background: $surface; border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
.skeleton-info {
|
||||||
|
flex: 1; display: flex; flex-direction: column; gap: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
.empty-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -313,8 +337,20 @@ onReachBottom(() => {
|
|||||||
padding: 120rpx 0;
|
padding: 120rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-text, .empty-text {
|
.empty-icon-wrap {
|
||||||
font-size: $font-lg;
|
width: 120rpx; height: 120rpx; border-radius: 50%;
|
||||||
|
background: $surface-warm; @include flex-center;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: $font-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: $font-md;
|
||||||
color: $text-muted;
|
color: $text-muted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,10 @@
|
|||||||
<text class="mi-label">分类管理</text>
|
<text class="mi-label">分类管理</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
</view>
|
</view>
|
||||||
|
<view class="menu-item" @tap="goTagManage">
|
||||||
|
<text class="mi-label">标签管理</text>
|
||||||
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
<view class="menu-item" @tap="goRecurring">
|
<view class="menu-item" @tap="goRecurring">
|
||||||
<text class="mi-label">周期账单</text>
|
<text class="mi-label">周期账单</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
@@ -59,6 +63,10 @@
|
|||||||
<text class="mi-label">数据导出</text>
|
<text class="mi-label">数据导出</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
</view>
|
</view>
|
||||||
|
<view class="menu-item" @tap="goDataImport">
|
||||||
|
<text class="mi-label">数据导入</text>
|
||||||
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="menu-card mt">
|
<view class="menu-card mt">
|
||||||
@@ -83,6 +91,10 @@
|
|||||||
<text class="mi-label">管理后台</text>
|
<text class="mi-label">管理后台</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
</view>
|
</view>
|
||||||
|
<view class="menu-item" @tap="goBackupManage">
|
||||||
|
<text class="mi-label">数据备份</text>
|
||||||
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 关于弹窗 -->
|
<!-- 关于弹窗 -->
|
||||||
@@ -142,6 +154,16 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 导出方式 -->
|
||||||
|
<view class="export-section">
|
||||||
|
<text class="export-label">导出方式</text>
|
||||||
|
<view class="export-tabs">
|
||||||
|
<view class="export-tab" :class="{ active: exportMode === 'local' }" @tap="exportMode = 'local'">本地导出</view>
|
||||||
|
<view class="export-tab" :class="{ active: exportMode === 'server' }" @tap="exportMode = 'server'">服务端导出</view>
|
||||||
|
</view>
|
||||||
|
<text class="export-hint" v-if="exportMode === 'server'">服务端导出支持大数据量,直接从服务器下载文件</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 预览 & 导出 -->
|
<!-- 预览 & 导出 -->
|
||||||
<view class="export-footer">
|
<view class="export-footer">
|
||||||
<text class="export-preview">共 <text class="export-count">{{ exportCount }}</text> 条记录</text>
|
<text class="export-preview">共 <text class="export-count">{{ exportCount }}</text> 条记录</text>
|
||||||
@@ -227,6 +249,7 @@ const exportStartDate = ref(getLocalDateStr().replace(/-/g, '-').replace(/(\d+)$
|
|||||||
const exportEndDate = ref(getLocalDateStr())
|
const exportEndDate = ref(getLocalDateStr())
|
||||||
const exportType = ref<'all' | 'expense' | 'income'>('all')
|
const exportType = ref<'all' | 'expense' | 'income'>('all')
|
||||||
const exportFormat = ref<'csv' | 'json'>('csv')
|
const exportFormat = ref<'csv' | 'json'>('csv')
|
||||||
|
const exportMode = ref<'local' | 'server'>('local')
|
||||||
const exportCount = ref(0)
|
const exportCount = ref(0)
|
||||||
const exportLoading = ref(false)
|
const exportLoading = ref(false)
|
||||||
|
|
||||||
@@ -305,6 +328,10 @@ function goCategoryManage() {
|
|||||||
uni.navigateTo({ url: '/pages/category-manage/index' })
|
uni.navigateTo({ url: '/pages/category-manage/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goTagManage() {
|
||||||
|
uni.navigateTo({ url: '/pages/tag-manage/index' })
|
||||||
|
}
|
||||||
|
|
||||||
function goRecurring() {
|
function goRecurring() {
|
||||||
uni.navigateTo({ url: '/pages/recurring/index' })
|
uni.navigateTo({ url: '/pages/recurring/index' })
|
||||||
}
|
}
|
||||||
@@ -317,6 +344,14 @@ function goFeedback() {
|
|||||||
uni.navigateTo({ url: '/pages/feedback/index' })
|
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() {
|
function goProfileEdit() {
|
||||||
uni.navigateTo({ url: '/pages/profile-edit/index' })
|
uni.navigateTo({ url: '/pages/profile-edit/index' })
|
||||||
}
|
}
|
||||||
@@ -416,6 +451,49 @@ function getLocalDateStr(): string {
|
|||||||
|
|
||||||
async function doExport() {
|
async function doExport() {
|
||||||
if (exportCount.value === 0 || exportLoading.value) return
|
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
|
exportLoading.value = true
|
||||||
uni.showLoading({ title: '导出中 0%' })
|
uni.showLoading({ title: '导出中 0%' })
|
||||||
try {
|
try {
|
||||||
@@ -965,4 +1043,11 @@ function saveFile(content: string, fileName: string, mimeType: string) {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $surface;
|
color: $surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-hint {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-muted;
|
||||||
|
display: block;
|
||||||
|
margin-top: $space-sm;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,15 +20,25 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 加载/空/列表 -->
|
<!-- 加载/空/列表 -->
|
||||||
<view v-if="loading" class="loading-box">
|
<view v-if="loading" class="skeleton-list">
|
||||||
<text class="loading-text">加载中...</text>
|
<view v-for="i in 3" :key="i" class="skeleton-card">
|
||||||
|
<Skeleton width="88rpx" height="88rpx" variant="circle" />
|
||||||
|
<view class="skeleton-info">
|
||||||
|
<Skeleton width="200rpx" height="28rpx" variant="text" />
|
||||||
|
<Skeleton width="300rpx" height="24rpx" variant="text" />
|
||||||
|
</view>
|
||||||
|
<Skeleton width="120rpx" height="32rpx" variant="rect" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else-if="list.length === 0" class="empty-box">
|
<view v-else-if="list.length === 0" class="empty-box">
|
||||||
|
<view class="empty-icon-wrap">
|
||||||
<Icon name="edit" :size="48" color="#BFB3B3" />
|
<Icon name="edit" :size="48" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
<text class="empty-text">暂无周期账单</text>
|
<text class="empty-text">暂无周期账单</text>
|
||||||
<text class="empty-hint">设置房租、订阅、工资等定期收支</text>
|
<text class="empty-hint">设置房租、订阅、工资等定期收支</text>
|
||||||
<view class="empty-btn" @tap="openAddModal">
|
<view class="empty-btn" @tap="openAddModal">
|
||||||
|
<Icon name="plus" :size="24" color="#FFFFFF" />
|
||||||
<text class="empty-btn-text">添加周期账单</text>
|
<text class="empty-btn-text">添加周期账单</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -162,6 +172,7 @@ import { getRecurringTemplates, createRecurringTemplate, updateRecurringTemplate
|
|||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||||||
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
|
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
|
||||||
|
import Skeleton from '@/components/Skeleton/Skeleton.vue'
|
||||||
import type { RecurringTemplate } from '@/api/recurring'
|
import type { RecurringTemplate } from '@/api/recurring'
|
||||||
|
|
||||||
const catStore = useCategoryStore()
|
const catStore = useCategoryStore()
|
||||||
@@ -361,16 +372,35 @@ function goBack() { uni.navigateBack() }
|
|||||||
}
|
}
|
||||||
.sync-text { font-size: $font-md; color: #43A047; font-weight: 500; }
|
.sync-text { font-size: $font-md; color: #43A047; font-weight: 500; }
|
||||||
|
|
||||||
.loading-box, .empty-box {
|
// 骨架屏
|
||||||
|
.skeleton-list { padding: 0 $space-xl; }
|
||||||
|
.skeleton-card {
|
||||||
|
display: flex; align-items: center; gap: $space-md;
|
||||||
|
padding: $space-lg; margin-bottom: $space-sm;
|
||||||
|
background: $surface; border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
}
|
||||||
|
.skeleton-info {
|
||||||
|
flex: 1; display: flex; flex-direction: column; gap: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
.empty-box {
|
||||||
display: flex; flex-direction: column; align-items: center;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
padding: 120rpx 0; gap: $space-sm;
|
padding: 120rpx 0; gap: $space-sm;
|
||||||
}
|
}
|
||||||
.loading-text, .empty-text { font-size: $font-lg; color: $text-muted; }
|
.empty-icon-wrap {
|
||||||
|
width: 120rpx; height: 120rpx; border-radius: 50%;
|
||||||
|
background: $surface-warm; @include flex-center;
|
||||||
|
margin-bottom: $space-sm;
|
||||||
|
}
|
||||||
|
.empty-text { font-size: $font-xl; font-weight: 600; color: $text; }
|
||||||
.empty-hint { font-size: $font-md; color: $text-muted; margin-top: -$space-xs; }
|
.empty-hint { font-size: $font-md; color: $text-muted; margin-top: -$space-xs; }
|
||||||
.empty-btn {
|
.empty-btn {
|
||||||
margin-top: $space-lg; padding: $space-sm $space-xl;
|
margin-top: $space-lg; padding: $space-sm $space-xl;
|
||||||
background: $primary; border-radius: $radius-lg;
|
background: linear-gradient(135deg, $primary, $primary-dark);
|
||||||
&:active { opacity: 0.8; }
|
border-radius: $radius-lg; display: flex; align-items: center; gap: $space-xs;
|
||||||
|
&:active { opacity: 0.8; transform: scale(0.96); }
|
||||||
}
|
}
|
||||||
.empty-btn-text { font-size: $font-base; color: $surface; font-weight: 600; }
|
.empty-btn-text { font-size: $font-base; color: $surface; font-weight: 600; }
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="period-wrap">
|
||||||
|
<view class="period-tabs">
|
||||||
|
<view class="period-tab" :class="{ active: period === 'week' }" @tap="switchPeriod('week')">周</view>
|
||||||
|
<view class="period-tab" :class="{ active: period === 'month' }" @tap="switchPeriod('month')">月</view>
|
||||||
|
<view class="period-tab" :class="{ active: period === 'year' }" @tap="switchPeriod('year')">年</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="overview-card">
|
<view class="overview-card">
|
||||||
<view class="ov-item">
|
<view class="ov-item">
|
||||||
<text class="ov-label">{{ tabType === 'expense' ? '总支出' : '总收入' }}</text>
|
<text class="ov-label">{{ tabType === 'expense' ? '总支出' : '总收入' }}</text>
|
||||||
@@ -95,7 +103,7 @@
|
|||||||
/>
|
/>
|
||||||
<view class="trend-list">
|
<view class="trend-list">
|
||||||
<view v-for="point in trendData" :key="point.date" class="trend-item">
|
<view v-for="point in trendData" :key="point.date" class="trend-item">
|
||||||
<text class="trend-date">{{ point.date.slice(8) }}日</text>
|
<text class="trend-date">{{ point.label || (point.date.slice(8) + '日') }}</text>
|
||||||
<view class="trend-bar-bg">
|
<view class="trend-bar-bg">
|
||||||
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
|
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
|
||||||
</view>
|
</view>
|
||||||
@@ -139,6 +147,7 @@ const configStore = useConfigStore()
|
|||||||
|
|
||||||
const currentMonth = ref(getCurrentMonth())
|
const currentMonth = ref(getCurrentMonth())
|
||||||
const tabType = ref<'expense' | 'income'>('expense')
|
const tabType = ref<'expense' | 'income'>('expense')
|
||||||
|
const period = ref<'week' | 'month' | 'year'>('month')
|
||||||
const { overview, categoryStats, trendData } = storeToRefs(statsStore)
|
const { overview, categoryStats, trendData } = storeToRefs(statsStore)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
@@ -176,7 +185,7 @@ const categoryChartData = computed(() => {
|
|||||||
const trendChartData = computed(() => {
|
const trendChartData = computed(() => {
|
||||||
if (trendData.value.length === 0) return null
|
if (trendData.value.length === 0) return null
|
||||||
return {
|
return {
|
||||||
categories: trendData.value.map(p => p.date.slice(8) + '日'),
|
categories: trendData.value.map(p => p.label || (p.date.slice(8) + '日')),
|
||||||
series: [{
|
series: [{
|
||||||
name: tabType.value === 'expense' ? '支出' : '收入',
|
name: tabType.value === 'expense' ? '支出' : '收入',
|
||||||
data: trendData.value.map(p => p.amount / 100)
|
data: trendData.value.map(p => p.amount / 100)
|
||||||
@@ -241,6 +250,8 @@ onShow(() => {
|
|||||||
watch(currentMonth, () => loadData())
|
watch(currentMonth, () => loadData())
|
||||||
// tab 切换时只请求分类和趋势数据
|
// tab 切换时只请求分类和趋势数据
|
||||||
watch(tabType, () => loadTabData())
|
watch(tabType, () => loadTabData())
|
||||||
|
// 周期切换时重新加载数据
|
||||||
|
watch(period, () => loadData())
|
||||||
|
|
||||||
async function loadData(silent = false) {
|
async function loadData(silent = false) {
|
||||||
const seq = ++loadSeq
|
const seq = ++loadSeq
|
||||||
@@ -249,8 +260,8 @@ async function loadData(silent = false) {
|
|||||||
loadError.value = false
|
loadError.value = false
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
statsStore.fetchOverview(currentMonth.value),
|
statsStore.fetchOverview(currentMonth.value),
|
||||||
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
|
statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value),
|
||||||
statsStore.fetchTrend(currentMonth.value, tabType.value),
|
statsStore.fetchTrend(currentMonth.value, tabType.value, period.value),
|
||||||
fetchMaxSingle(),
|
fetchMaxSingle(),
|
||||||
fetchPrevMonthData()
|
fetchPrevMonthData()
|
||||||
])
|
])
|
||||||
@@ -269,8 +280,8 @@ async function loadTabData() {
|
|||||||
const seq = ++loadSeq
|
const seq = ++loadSeq
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
|
statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value),
|
||||||
statsStore.fetchTrend(currentMonth.value, tabType.value),
|
statsStore.fetchTrend(currentMonth.value, tabType.value, period.value),
|
||||||
fetchMaxSingle()
|
fetchMaxSingle()
|
||||||
])
|
])
|
||||||
if (seq !== loadSeq) return
|
if (seq !== loadSeq) return
|
||||||
@@ -339,6 +350,10 @@ function nextMonth() {
|
|||||||
currentMonth.value = m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`
|
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) {
|
function getBarWidth(amount: number) {
|
||||||
const max = Math.max(...trendData.value.map(t => t.amount), 1)
|
const max = Math.max(...trendData.value.map(t => t.amount), 1)
|
||||||
return (amount / max) * 100
|
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 {
|
.overview-card, .chart-card {
|
||||||
@include card($radius-2xl);
|
@include card($radius-2xl);
|
||||||
margin: 0 $space-xl $space-lg;
|
margin: 0 $space-xl $space-lg;
|
||||||
|
|||||||
371
client/src/pages/tag-manage/index.vue
Normal file
371
client/src/pages/tag-manage/index.vue
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="header-fixed">
|
||||||
|
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||||
|
<view class="nav-bar">
|
||||||
|
<view class="nav-back" @tap="goBack">
|
||||||
|
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
|
||||||
|
</view>
|
||||||
|
<text class="nav-title">标签管理</text>
|
||||||
|
<view class="nav-placeholder"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tag-list" v-if="tagStore.tags.length > 0">
|
||||||
|
<view v-for="tag in tagStore.tags" :key="tag.id" class="tag-row">
|
||||||
|
<view class="tag-info" @tap="onEdit(tag)">
|
||||||
|
<view class="tag-dot" :style="{ background: tag.color }"></view>
|
||||||
|
<text class="tag-name">{{ tag.name }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="tag-actions">
|
||||||
|
<view class="action-btn" @tap="onEdit(tag)">
|
||||||
|
<Icon name="edit" :size="24" color="#8B7E7E" />
|
||||||
|
</view>
|
||||||
|
<view class="action-btn" @tap="onDelete(tag)">
|
||||||
|
<Icon name="trash" :size="24" color="#FF6B6B" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="empty" v-else-if="!tagStore.loading">
|
||||||
|
<Icon name="tag" :size="48" color="#BFB3B3" />
|
||||||
|
<text class="empty-text">暂无标签,点击下方按钮添加</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 浮动添加按钮 -->
|
||||||
|
<view class="fab" @tap="showAddModal = true">
|
||||||
|
<Icon name="plus" :size="48" color="#FFFFFF" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 添加弹窗 -->
|
||||||
|
<view class="modal-mask" v-if="showAddModal" @tap="showAddModal = false">
|
||||||
|
<view class="modal" @tap.stop>
|
||||||
|
<text class="modal-title">添加标签</text>
|
||||||
|
<input class="modal-input" v-model="newName" placeholder="标签名称" maxlength="20" />
|
||||||
|
<view class="color-picker">
|
||||||
|
<view
|
||||||
|
v-for="c in colorOptions" :key="c"
|
||||||
|
class="color-dot"
|
||||||
|
:class="{ selected: selectedColor === c }"
|
||||||
|
:style="{ background: c }"
|
||||||
|
@tap="selectedColor = c"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="modal-btns">
|
||||||
|
<view class="modal-btn cancel" @tap="showAddModal = false">取消</view>
|
||||||
|
<view class="modal-btn confirm" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 编辑弹窗 -->
|
||||||
|
<view class="modal-mask" v-if="showEditModal" @tap="showEditModal = false">
|
||||||
|
<view class="modal" @tap.stop>
|
||||||
|
<text class="modal-title">编辑标签</text>
|
||||||
|
<input class="modal-input" v-model="editName" placeholder="标签名称" maxlength="20" />
|
||||||
|
<view class="color-picker">
|
||||||
|
<view
|
||||||
|
v-for="c in colorOptions" :key="c"
|
||||||
|
class="color-dot"
|
||||||
|
:class="{ selected: editColor === c }"
|
||||||
|
:style="{ background: c }"
|
||||||
|
@tap="editColor = c"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view class="modal-btns">
|
||||||
|
<view class="modal-btn cancel" @tap="showEditModal = false">取消</view>
|
||||||
|
<view class="modal-btn confirm" @tap="onEditConfirm">确定</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
|
import { useTagStore } from '@/stores/tag'
|
||||||
|
import type { Tag } from '@/api/tag'
|
||||||
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
|
import { statusBarHeight } from '@/utils/system'
|
||||||
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
|
||||||
|
const tagStore = useTagStore()
|
||||||
|
|
||||||
|
const showAddModal = ref(false)
|
||||||
|
const newName = ref('')
|
||||||
|
const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
|
||||||
|
const selectedColor = ref(colorOptions[0])
|
||||||
|
|
||||||
|
const showEditModal = ref(false)
|
||||||
|
const editingTag = ref<Tag | null>(null)
|
||||||
|
const editName = ref('')
|
||||||
|
const editColor = ref('')
|
||||||
|
|
||||||
|
const initialLoaded = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await waitForReady()
|
||||||
|
await tagStore.fetchTags()
|
||||||
|
initialLoaded.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (initialLoaded.value) tagStore.fetchTags()
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await tagStore.fetchTags()
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAdd() {
|
||||||
|
const name = newName.value.trim()
|
||||||
|
if (!name) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tagStore.addTag({ name, color: selectedColor.value })
|
||||||
|
newName.value = ''
|
||||||
|
showAddModal.value = false
|
||||||
|
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '添加失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEdit(tag: Tag) {
|
||||||
|
editingTag.value = tag
|
||||||
|
editName.value = tag.name
|
||||||
|
editColor.value = tag.color
|
||||||
|
showEditModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEditConfirm() {
|
||||||
|
if (!editingTag.value) return
|
||||||
|
const name = editName.value.trim()
|
||||||
|
if (!name) {
|
||||||
|
uni.showToast({ title: '请输入名称', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tagStore.updateTag(editingTag.value.id, { name, color: editColor.value })
|
||||||
|
showEditModal.value = false
|
||||||
|
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '修改失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(tag: Tag) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除标签',
|
||||||
|
content: `确定删除「${tag.name}」?关联的交易记录不会被删除。`,
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await tagStore.deleteTag(tag.id)
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
@include page-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fixed {
|
||||||
|
@include sticky-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back {
|
||||||
|
@include nav-back;
|
||||||
|
border-radius: 50%;
|
||||||
|
&:active { background: $border; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder { width: 72rpx; }
|
||||||
|
|
||||||
|
.tag-list { padding: 0 $space-xl; }
|
||||||
|
|
||||||
|
.tag-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: $space-md 0;
|
||||||
|
border-bottom: 1rpx solid $border;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
flex: 1;
|
||||||
|
padding: $space-xs 0;
|
||||||
|
|
||||||
|
&:active { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-dot {
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-name { font-size: $font-lg; color: $text; font-weight: 500; }
|
||||||
|
|
||||||
|
.tag-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
&:active { background: $border; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
@include state-box;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text { @include state-text; }
|
||||||
|
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
right: $space-xl;
|
||||||
|
bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||||
|
width: 112rpx;
|
||||||
|
height: 112rpx;
|
||||||
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 8rpx 8rpx 24rpx rgba(255, 140, 105, 0.4);
|
||||||
|
z-index: 50;
|
||||||
|
&:active { transform: scale(0.95); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: 600rpx;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $space-lg;
|
||||||
|
padding: $space-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: $font-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-input {
|
||||||
|
height: 96rpx;
|
||||||
|
background: $bg;
|
||||||
|
border-radius: $space-md;
|
||||||
|
padding: 0 $space-lg;
|
||||||
|
font-size: $font-lg;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-lg;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dot {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4rpx solid transparent;
|
||||||
|
transition: all $transition-normal;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: $text;
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
&:active { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: $space-md;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.cancel {
|
||||||
|
background: $bg;
|
||||||
|
color: $text-sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.confirm {
|
||||||
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
|
color: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled { opacity: 0.5; }
|
||||||
|
&:active { opacity: 0.8; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -36,8 +36,8 @@ export const useStatsStore = defineStore('stats', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCategoryStats(month?: string, type: string = 'expense') {
|
async function fetchCategoryStats(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
|
||||||
const data = await api.getCategoryStats(month || getCurrentMonth(), type, getGroupId())
|
const data = await api.getCategoryStats(month || getCurrentMonth(), type, getGroupId(), period)
|
||||||
categoryStats.value = Array.isArray(data) ? data.map(item => ({
|
categoryStats.value = Array.isArray(data) ? data.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
amount: Number(item.amount) || 0,
|
amount: Number(item.amount) || 0,
|
||||||
@@ -45,8 +45,8 @@ export const useStatsStore = defineStore('stats', () => {
|
|||||||
})) : []
|
})) : []
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTrend(month?: string, type: string = 'expense') {
|
async function fetchTrend(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
|
||||||
const data = await api.getTrend(month || getCurrentMonth(), type, getGroupId())
|
const data = await api.getTrend(month || getCurrentMonth(), type, getGroupId(), period)
|
||||||
trendData.value = Array.isArray(data) ? data.map(item => ({
|
trendData.value = Array.isArray(data) ? data.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
amount: Number(item.amount) || 0
|
amount: Number(item.amount) || 0
|
||||||
|
|||||||
52
client/src/stores/tag.ts
Normal file
52
client/src/stores/tag.ts
Normal file
@@ -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<api.Tag[]>([])
|
||||||
|
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 }
|
||||||
|
})
|
||||||
@@ -247,3 +247,65 @@
|
|||||||
padding-bottom: constant(safe-area-inset-bottom);
|
padding-bottom: constant(safe-area-inset-bottom);
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 入场动画 =====
|
||||||
|
|
||||||
|
// 卡片淡入上浮
|
||||||
|
@mixin fade-in-up($delay: 0s) {
|
||||||
|
animation: fadeInUp 0.4s cubic-bezier(0.23, 1, 0.32, 1) $delay both;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表项交错淡入
|
||||||
|
@mixin stagger-item($index: 0, $base-delay: 0.05s) {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeInUp 0.35s cubic-bezier(0.23, 1, 0.32, 1) ($index * $base-delay) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数字跳动
|
||||||
|
@mixin count-up {
|
||||||
|
animation: countPulse 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹性缩放
|
||||||
|
@mixin bounce-in($delay: 0s) {
|
||||||
|
animation: bounceIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) $delay both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24rpx);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounceIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes countPulse {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
40% { transform: scale(1.08); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尊重减弱动画偏好
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fade-in-up,
|
||||||
|
.stagger-item,
|
||||||
|
.bounce-in {
|
||||||
|
animation: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
577
docs/arch-alignment.md
Normal file
577
docs/arch-alignment.md
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
# 小菜记账 增量架构设计文档
|
||||||
|
|
||||||
|
> 版本:v1.0 | 架构师:高见远(Gao) | 日期:2025-07
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part A: 系统设计
|
||||||
|
|
||||||
|
### 1. 实现方案 + 框架选型
|
||||||
|
|
||||||
|
#### 1.1 分类拖拽排序(P0-1)
|
||||||
|
|
||||||
|
**技术挑战**:微信小程序不支持 HTML5 Drag and Drop API,Uni-app 的 `movable-area`/`movable-view` 适合单元素拖拽而非列表重排。
|
||||||
|
|
||||||
|
**方案**:基于 Touch 事件的自定义拖拽排序组件 `DragSortList`。
|
||||||
|
|
||||||
|
- 监听 `@touchstart` / `@touchmove` / `@touchend` 三组事件
|
||||||
|
- 拖拽时通过 `transform: translateY()` 实现元素位移动画
|
||||||
|
- 拖拽过程中实时计算目标索引,交换数组元素位置
|
||||||
|
- 松手后调用已就绪的 `sortCategories(ids)` API 批量更新排序
|
||||||
|
- 支出/收入各自独立排序(复用现有 `tabType` 切换 + `currentCategories` computed)
|
||||||
|
- **不引入第三方拖拽库**——Uni-app 小程序环境下无成熟可用方案,自研可控性更高
|
||||||
|
|
||||||
|
#### 1.2 服务端导出流式响应(P2-3)
|
||||||
|
|
||||||
|
**方案**:Node.js 原生 Stream + `pipeline()` 管道。
|
||||||
|
|
||||||
|
- **< 1 万条**:使用 `Readable.from()` 创建内存流,逐行 push 数据,通过 `pipeline()` 管道传输到 Response
|
||||||
|
- **≥ 1 万条**:先写入临时文件(`os.tmpdir()`),再 `fs.createReadStream()` 流式返回,完成后删除临时文件
|
||||||
|
- CSV 格式:手动拼装(无需额外依赖),添加 BOM 头确保中文兼容
|
||||||
|
- JSON 格式:流式写入 `[` + 逐条 `,` 分隔 + `]`,避免全量 JSON.stringify
|
||||||
|
- Response Headers:`Content-Type: text/csv; charset=utf-8` / `application/json; charset=utf-8`,`Content-Disposition: attachment; filename=...`
|
||||||
|
- 超时保护:流式响应不设 `express.json()` body 限制,但设 60s 超时
|
||||||
|
|
||||||
|
#### 1.3 标签系统数据库设计(P3-2~4)
|
||||||
|
|
||||||
|
**设计原则**:标签不区分收支类型,跨分类使用,轻量关联表。
|
||||||
|
|
||||||
|
- `tags` 表:用户维度,每用户最多 20 个标签,8 色预设
|
||||||
|
- `transaction_tags` 表:多对多关联,每笔交易最多 5 个标签
|
||||||
|
- 标签与交易在同一事务中写入(`POST/PUT /transactions` 扩展 `tagIds` 字段)
|
||||||
|
- 查询交易列表时 LEFT JOIN 聚合标签信息(避免 N+1 查询)
|
||||||
|
- 统计按标签聚合使用 `GROUP BY tag_id`
|
||||||
|
|
||||||
|
#### 1.4 数据导入(P2-2)
|
||||||
|
|
||||||
|
**方案**:JSON 格式导入,服务端校验 + 批量写入 + 去重。
|
||||||
|
|
||||||
|
- 接收 JSON 数组,校验字段格式(必填:date, type, amount, category_name)
|
||||||
|
- 按 `date + type + amount + note` 组合去重(查现有记录比对)
|
||||||
|
- 使用 `INSERT ... VALUES (...), (...), ...` 批量写入(每批 100 条)
|
||||||
|
- 返回结果:`{ total, imported, skipped, errors[] }`
|
||||||
|
|
||||||
|
#### 1.5 统计年度/周视图(P2-1)
|
||||||
|
|
||||||
|
**方案**:后端 `period` 参数扩展 + 前端维度切换器。
|
||||||
|
|
||||||
|
- 后端:`GET /stats/category` 和 `GET /stats/trend` 新增 `period=week|month|year` 参数
|
||||||
|
- `week`:基于当前日期计算本周一至本周日,趋势 X 轴为周一~周日
|
||||||
|
- `month`:保持现有行为(默认)
|
||||||
|
- `year`:基于当前年份 1-12 月,趋势 X 轴为 1月~12月
|
||||||
|
- 年视图日期范围计算:`startDate = ${year}-01-01`, `endDate = ${year}-12-31`
|
||||||
|
- 周视图日期范围计算:获取本周一和本周日
|
||||||
|
- 趋势数据 GROUP BY 逻辑:年视图按月 GROUP,周视图按日 GROUP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 文件列表及相对路径
|
||||||
|
|
||||||
|
#### 2.1 新建文件
|
||||||
|
|
||||||
|
| 文件路径 | 说明 |
|
||||||
|
|---------|------|
|
||||||
|
| `client/src/api/backup.ts` | 备份管理 API(下载备份) |
|
||||||
|
| `client/src/api/tag.ts` | 标签 CRUD + 统计 API |
|
||||||
|
| `client/src/api/export.ts` | 服务端导出 API |
|
||||||
|
| `client/src/api/health.ts` | 健康检查 API |
|
||||||
|
| `client/src/stores/tag.ts` | 标签 Pinia Store |
|
||||||
|
| `client/src/components/DragSortList/DragSortList.vue` | 拖拽排序列表组件 |
|
||||||
|
| `client/src/pages/backup-manage/index.vue` | 备份管理页面(仅管理员) |
|
||||||
|
| `client/src/pages/data-import/index.vue` | 数据导入页面 |
|
||||||
|
| `client/src/pages/tag-manage/index.vue` | 标签管理页面 |
|
||||||
|
| `server/src/routes/tag.ts` | 标签 CRUD + 按标签统计路由 |
|
||||||
|
| `server/src/routes/export.ts` | 服务端导出路由 |
|
||||||
|
|
||||||
|
#### 2.2 修改文件
|
||||||
|
|
||||||
|
| 文件路径 | 主要变更 |
|
||||||
|
|---------|---------|
|
||||||
|
| `server/src/db/schema.sql` | 新增 tags、transaction_tags 表定义 |
|
||||||
|
| `server/src/db/init.ts` | 新增 tags、transaction_tags 表迁移 |
|
||||||
|
| `server/src/routes/feedback.ts` | 新增 `GET /mine` 端点(用户查看自己的反馈) |
|
||||||
|
| `server/src/routes/backup.ts` | 新增 `GET /:id/download` 端点(临时签名 URL) |
|
||||||
|
| `server/src/routes/stats.ts` | category/trend 端点增加 `period` 参数支持 |
|
||||||
|
| `server/src/routes/transaction.ts` | POST/PUT 支持 `tagIds`;GET 支持 `tagId` 筛选;GET/:id 返回 tags |
|
||||||
|
| `server/src/index.ts` | health 端点移到 authMiddleware 之前;注册 tag、export 路由 |
|
||||||
|
| `client/src/api/feedback.ts` | 新增 `getMyFeedbacks()` API 函数 |
|
||||||
|
| `client/src/api/stats.ts` | 修改 `getCategoryStats`、`getTrend` 支持 `period` 参数 |
|
||||||
|
| `client/src/api/transaction.ts` | 新增 `importTransactions()`;类型扩展 `tagIds`、`tagId` |
|
||||||
|
| `client/src/api/admin.ts` | 新增 `getHealth()` API 函数 |
|
||||||
|
| `client/src/stores/category.ts` | 无需修改(sortCategories 已就绪) |
|
||||||
|
| `client/src/stores/stats.ts` | fetchCategoryStats/fetchTrend 支持 period 参数 |
|
||||||
|
| `client/src/pages/category-manage/index.vue` | 集成 DragSortList 拖拽排序 |
|
||||||
|
| `client/src/pages/feedback/index.vue` | 新增"我的反馈"Tab |
|
||||||
|
| `client/src/pages/stats/index.vue` | 添加维度切换器(周/月/年) |
|
||||||
|
| `client/src/pages/profile/index.vue` | 导出面板增加"服务端导出"选项 |
|
||||||
|
| `client/src/pages/add/index.vue` | 添加标签选择入口 |
|
||||||
|
| `client/src/pages/bills/index.vue` | 添加标签筛选功能 |
|
||||||
|
| `client/src/pages/admin/index.vue` | 添加服务状态卡片 |
|
||||||
|
| `client/src/pages.json` | 注册新页面路由 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 数据结构和接口(类图)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class Tag {
|
||||||
|
+int id
|
||||||
|
+int user_id
|
||||||
|
+string name
|
||||||
|
+string color
|
||||||
|
+string created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransactionTag {
|
||||||
|
+int transaction_id
|
||||||
|
+int tag_id
|
||||||
|
}
|
||||||
|
|
||||||
|
class Transaction {
|
||||||
|
+int id
|
||||||
|
+int user_id
|
||||||
|
+int amount
|
||||||
|
+string type
|
||||||
|
+int category_id
|
||||||
|
+string note
|
||||||
|
+string date
|
||||||
|
+int group_id
|
||||||
|
+int recurring_id
|
||||||
|
+Tag[] tags
|
||||||
|
}
|
||||||
|
|
||||||
|
class Feedback {
|
||||||
|
+int id
|
||||||
|
+int user_id
|
||||||
|
+string type
|
||||||
|
+string content
|
||||||
|
+string contact
|
||||||
|
+string status
|
||||||
|
+string admin_reply
|
||||||
|
+string created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
class Category {
|
||||||
|
+int id
|
||||||
|
+int user_id
|
||||||
|
+string name
|
||||||
|
+string icon
|
||||||
|
+string color
|
||||||
|
+string type
|
||||||
|
+int sort_order
|
||||||
|
+int is_custom
|
||||||
|
}
|
||||||
|
|
||||||
|
Transaction "1" --o "*" TransactionTag : has
|
||||||
|
Tag "1" --o "*" TransactionTag : referenced_by
|
||||||
|
Transaction ..> Category : belongs_to
|
||||||
|
|
||||||
|
note for Tag "每用户最多 20 个标签\n8 色预设颜色选择器\n标签不区分收支类型"
|
||||||
|
note for TransactionTag "复合主键 (transaction_id, tag_id)\n每笔交易最多 5 个标签"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1 新增数据库表
|
||||||
|
|
||||||
|
**tags 表**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
name VARCHAR(20) NOT NULL COMMENT '标签名称',
|
||||||
|
color VARCHAR(20) NOT NULL DEFAULT '#FF8C69' COMMENT '标签颜色',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_user_name (user_id, name),
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
```
|
||||||
|
|
||||||
|
**transaction_tags 表**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS transaction_tags (
|
||||||
|
transaction_id INT NOT NULL,
|
||||||
|
tag_id INT NOT NULL,
|
||||||
|
PRIMARY KEY (transaction_id, tag_id),
|
||||||
|
INDEX idx_tag (tag_id),
|
||||||
|
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 新增/修改 API 端点
|
||||||
|
|
||||||
|
##### 标签 CRUD
|
||||||
|
|
||||||
|
| 方法 | 路径 | 请求 | 响应 | 说明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| GET | `/api/tags` | — | `{ list: Tag[] }` | 获取当前用户所有标签 |
|
||||||
|
| POST | `/api/tags` | `{ name, color }` | `{ id }` | 创建标签(限 20 个/用户) |
|
||||||
|
| PUT | `/api/tags/:id` | `{ name?, color? }` | — | 更新标签 |
|
||||||
|
| DELETE | `/api/tags/:id` | — | — | 删除标签(级联删除关联) |
|
||||||
|
| GET | `/api/stats/by-tag` | `?month&period&type` | `TagStat[]` | 按标签统计 |
|
||||||
|
|
||||||
|
**TagStat 类型**:
|
||||||
|
```ts
|
||||||
|
{ id: number; name: string; color: string; amount: number; count: number }
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 用户反馈列表
|
||||||
|
|
||||||
|
| 方法 | 路径 | 请求 | 响应 | 说明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| GET | `/api/feedback/mine` | `?page&pageSize` | `{ list, total, page, pageSize }` | 当前用户的反馈列表 |
|
||||||
|
|
||||||
|
##### 备份下载
|
||||||
|
|
||||||
|
| 方法 | 路径 | 请求 | 响应 | 说明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| GET | `/api/backup/:id/download` | — | 文件流 | 下载备份文件(临时签名 URL,1h 有效) |
|
||||||
|
|
||||||
|
**实现方式**:在 `backup` 表中无需存储,使用 HMAC-SHA256 生成带过期时间的签名 token,下载时校验签名。签名格式:`backupId:timestamp:hmac-sha256(backupId:timestamp)`。将签名作为 `?token=xxx` 传给前端,下载端点校验 token 有效性(无需认证中间件)。
|
||||||
|
|
||||||
|
##### 统计年度/周视图
|
||||||
|
|
||||||
|
| 方法 | 路径 | 请求参数 | 说明 |
|
||||||
|
|------|------|---------|------|
|
||||||
|
| GET | `/api/stats/category` | 新增 `period=week\|month\|year` | 周/月/年分类统计 |
|
||||||
|
| GET | `/api/stats/trend` | 新增 `period=week\|month\|year` | 周/月/年趋势数据 |
|
||||||
|
|
||||||
|
**period 参数逻辑**:
|
||||||
|
- `week`(新增):`startDate` = 本周一,`endDate` = 本周日,趋势按日 GROUP
|
||||||
|
- `month`(默认):保持现有行为,趋势按日 GROUP
|
||||||
|
- `year`(新增):`startDate` = 当年1月1日,`endDate` = 当年12月31日,趋势按月 GROUP(`DATE_FORMAT(date, '%Y-%m')`)
|
||||||
|
|
||||||
|
**年视图趋势响应**(新增 `label` 字段):
|
||||||
|
```ts
|
||||||
|
{ label: string; date: string; amount: number }[]
|
||||||
|
// 年视图 label = "1月"~"12月"
|
||||||
|
// 周视图 label = "周一"~"周日"
|
||||||
|
// 月视图 label = "1日"~"31日"(保持兼容)
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 数据导入
|
||||||
|
|
||||||
|
| 方法 | 路径 | 请求 | 响应 | 说明 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| POST | `/api/transactions/import` | `{ items: ImportItem[] }` | `{ total, imported, skipped, errors[] }` | 批量导入 |
|
||||||
|
|
||||||
|
**ImportItem 类型**:
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
date: string // 必填,YYYY-MM-DD
|
||||||
|
type: string // 必填,expense | income
|
||||||
|
amount: number // 必填,单位:分
|
||||||
|
category_name: string // 可选,匹配不到则归入"未分类"
|
||||||
|
note?: string
|
||||||
|
tag_names?: string[] // 可选,按名称匹配标签
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 服务端导出
|
||||||
|
|
||||||
|
| 方法 | 路径 | 请求参数 | 响应 | 说明 |
|
||||||
|
|------|------|---------|------|------|
|
||||||
|
| GET | `/api/export` | `startDate, endDate, type, format=csv\|json` | 文件流 | 流式导出 |
|
||||||
|
|
||||||
|
##### 健康检查(改造)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/health` | 移到 `authMiddleware` 之前,无需认证 |
|
||||||
|
|
||||||
|
##### 交易记录(改造)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 变更 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/transactions` | 请求新增 `tagIds: number[]`(可选,最多 5 个) |
|
||||||
|
| PUT | `/api/transactions/:id` | 请求新增 `tagIds: number[]`(可选,最多 5 个,全量替换) |
|
||||||
|
| GET | `/api/transactions` | 新增 `tagId` 查询参数筛选 |
|
||||||
|
| GET | `/api/transactions/:id` | 响应新增 `tags: { id, name, color }[]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 程序调用流程(时序图)
|
||||||
|
|
||||||
|
#### 4.1 分类拖拽排序
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as 用户
|
||||||
|
participant P as category-manage
|
||||||
|
participant D as DragSortList
|
||||||
|
participant S as categoryStore
|
||||||
|
participant A as sortCategories API
|
||||||
|
participant B as PUT /categories/sort
|
||||||
|
|
||||||
|
U->>P: 长按分类项进入排序模式
|
||||||
|
P->>P: showSortMode = true
|
||||||
|
P->>D: 渲染 DragSortList (items=currentCategories)
|
||||||
|
U->>D: touchstart (记录起始位置)
|
||||||
|
U->>D: touchmove (计算偏移, 交换元素位置)
|
||||||
|
D->>D: 实时更新 items 数组顺序
|
||||||
|
U->>D: touchend (拖拽结束)
|
||||||
|
D->>P: @change事件 (新顺序ids)
|
||||||
|
P->>S: sortCategories(newIds)
|
||||||
|
S->>A: sortCategories(ids)
|
||||||
|
A->>B: PUT /categories/sort { ids }
|
||||||
|
B-->>A: { code: 0 }
|
||||||
|
A-->>S: 成功
|
||||||
|
S->>S: fetchCategories() 刷新
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 数据导入
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as 用户
|
||||||
|
participant P as data-import
|
||||||
|
participant A as transaction API
|
||||||
|
participant S as POST /transactions/import
|
||||||
|
participant DB as MySQL
|
||||||
|
|
||||||
|
U->>P: 选择 JSON 文件
|
||||||
|
P->>P: uni.chooseFile / 读取文件内容
|
||||||
|
P->>P: 解析 JSON,显示预览(条数、日期范围)
|
||||||
|
U->>P: 确认导入
|
||||||
|
P->>A: importTransactions(items)
|
||||||
|
A->>S: POST /transactions/import { items }
|
||||||
|
S->>S: 校验每条记录格式
|
||||||
|
S->>DB: 查询已有记录 (去重比对)
|
||||||
|
S->>DB: 批量 INSERT (每批100条)
|
||||||
|
S->>DB: 写入 transaction_tags (如有 tag_names)
|
||||||
|
S-->>A: { total, imported, skipped, errors }
|
||||||
|
A-->>P: 导入结果
|
||||||
|
P->>U: 显示导入结果(成功X条, 跳过Y条)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.3 标签关联交易
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as 用户
|
||||||
|
participant P as add/index
|
||||||
|
participant TS as tagStore
|
||||||
|
participant TA as transaction API
|
||||||
|
participant S as POST /transactions
|
||||||
|
participant DB as MySQL
|
||||||
|
|
||||||
|
U->>P: 点击"添加标签"
|
||||||
|
P->>P: 显示标签选择面板(已有标签 + 新建入口)
|
||||||
|
U->>P: 选择标签(最多5个)
|
||||||
|
P->>P: selectedTagIds 更新
|
||||||
|
U->>P: 保存交易
|
||||||
|
P->>TA: createTransaction({...data, tagIds})
|
||||||
|
TA->>S: POST /transactions { amount, type, ..., tagIds }
|
||||||
|
S->>S: 校验 tagIds (≤5, 属于当前用户)
|
||||||
|
S->>DB: INSERT INTO transactions
|
||||||
|
S->>DB: INSERT INTO transaction_tags (批量)
|
||||||
|
S-->>TA: { id }
|
||||||
|
TA-->>P: 成功
|
||||||
|
|
||||||
|
Note over P,U: 编辑交易时同理,PUT 全量替换 tagIds
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.4 服务端导出
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as 用户
|
||||||
|
participant P as profile/index
|
||||||
|
participant A as export API
|
||||||
|
participant S as GET /api/export
|
||||||
|
participant DB as MySQL
|
||||||
|
|
||||||
|
U->>P: 点击"服务端导出"
|
||||||
|
P->>A: serverExport(params)
|
||||||
|
A->>S: GET /api/export?startDate=&endDate=&type=&format=
|
||||||
|
S->>DB: SELECT COUNT(*) (判断数量)
|
||||||
|
alt 记录 < 10000
|
||||||
|
S->>DB: 流式查询 (cursor)
|
||||||
|
S->>S: Readable.from() 逐行生成 CSV/JSON
|
||||||
|
S-->>P: 流式响应 (Transfer-Encoding: chunked)
|
||||||
|
else 记录 ≥ 10000
|
||||||
|
S->>DB: 流式查询写入临时文件
|
||||||
|
S->>S: fs.createReadStream()
|
||||||
|
S-->>P: 流式响应
|
||||||
|
S->>S: 删除临时文件
|
||||||
|
end
|
||||||
|
P->>U: 保存文件到本地
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 待明确事项
|
||||||
|
|
||||||
|
| # | 事项 | 当前假设 |
|
||||||
|
|---|------|---------|
|
||||||
|
| 1 | 备份下载的签名 URL 是通过新端点直接下载,还是返回签名 URL 让前端跳转? | 采用直接下载方式:`GET /api/backup/:id/download?token=xxx`,后端校验 token 后流式返回文件 |
|
||||||
|
| 2 | 年视图/周视图的 `month` 参数如何处理? | 新增 `period` 参数,当 `period=year` 时 `month` 参数被忽略,使用当前年份;当 `period=week` 时使用当前周 |
|
||||||
|
| 3 | 导入 JSON 的 `category_name` 匹配不到时如何处理? | 归入"未分类"(category_id = NULL),不自动创建分类 |
|
||||||
|
| 4 | 标签预设 8 色具体色值? | 复用现有 `colorOptions`:`#FF8C69, #7BC67E, #5B9BD5, #FFD700, #FF69B4, #8B5CF6, #F97316, #06B6D4` |
|
||||||
|
| 5 | 健康检查移到 authMiddleware 前是否影响安全? | `/api/health` 仅返回 DB 连接状态和服务器时间,不泄露敏感信息,安全无影响 |
|
||||||
|
| 6 | 数据导入文件大小限制? | 前端限制 5MB,后端 `express.json({ limit: '5mb' })` 仅对导入端点放宽(当前全局 10kb) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part B: 任务分解
|
||||||
|
|
||||||
|
### 6. 需要新增的依赖包
|
||||||
|
|
||||||
|
#### 后端 (server)
|
||||||
|
|
||||||
|
```
|
||||||
|
无新增依赖
|
||||||
|
```
|
||||||
|
|
||||||
|
> 流式导出使用 Node.js 内置 `stream`、`fs`、`os` 模块,CSV 拼装手写无需 `json2csv`,签名 URL 使用已有 `crypto` 模块。
|
||||||
|
|
||||||
|
#### 前端 (client)
|
||||||
|
|
||||||
|
```
|
||||||
|
无新增依赖
|
||||||
|
```
|
||||||
|
|
||||||
|
> 拖拽排序使用 Touch 事件自研实现,不引入第三方拖拽库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 任务列表(按依赖顺序)
|
||||||
|
|
||||||
|
#### T01: 后端基础设施 + 数据库迁移
|
||||||
|
|
||||||
|
**描述**:新增 tags/transaction_tags 表、修改 health 端点位置、注册新路由模块、调整 express.json 限制。
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `server/src/db/schema.sql` — 新增 tags、transaction_tags 表定义
|
||||||
|
- `server/src/db/init.ts` — 新增迁移逻辑(创建 tags、transaction_tags 表)
|
||||||
|
- `server/src/index.ts` — health 移到 authMiddleware 前;注册 tag、export 路由;导入端点 body 限制调整
|
||||||
|
- `server/src/routes/tag.ts` — 新建:标签 CRUD 端点 + 按标签统计端点
|
||||||
|
- `server/src/routes/export.ts` — 新建:服务端流式导出端点
|
||||||
|
|
||||||
|
**依赖任务**:无
|
||||||
|
|
||||||
|
**优先级**:P0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### T02: 后端 API 改造(现有路由扩展)
|
||||||
|
|
||||||
|
**描述**:扩展现有路由以支持新功能——反馈 mine 端点、备份下载、交易 tagIds 支持、统计 period 参数。
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `server/src/routes/feedback.ts` — 新增 `GET /mine` 端点
|
||||||
|
- `server/src/routes/backup.ts` — 新增 `GET /:id/download` 端点(签名 URL 校验)
|
||||||
|
- `server/src/routes/transaction.ts` — POST/PUT 支持 tagIds;GET 支持 tagId 筛选;GET/:id 返回 tags
|
||||||
|
- `server/src/routes/stats.ts` — category/trend 增加 period 参数(week/year 视图)
|
||||||
|
|
||||||
|
**依赖任务**:T01(tags 表需先存在)
|
||||||
|
|
||||||
|
**优先级**:P0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### T03: 前端 API 层 + Store 层 + 新页面
|
||||||
|
|
||||||
|
**描述**:新增所有前端 API 函数和 Store,创建新页面(标签管理、备份管理、数据导入),创建拖拽组件。
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `client/src/api/feedback.ts` — 新增 `getMyFeedbacks()`
|
||||||
|
- `client/src/api/backup.ts` — 新建:备份列表 + 下载 API
|
||||||
|
- `client/src/api/tag.ts` — 新建:标签 CRUD + 统计 API
|
||||||
|
- `client/src/api/export.ts` — 新建:服务端导出 API
|
||||||
|
- `client/src/api/health.ts` — 新建:健康检查 API
|
||||||
|
- `client/src/api/stats.ts` — 修改:getCategoryStats/getTrend 支持 period
|
||||||
|
- `client/src/api/transaction.ts` — 新增 `importTransactions()`;类型扩展 tagIds/tagId
|
||||||
|
- `client/src/api/admin.ts` — 新增 `getHealth()`
|
||||||
|
- `client/src/stores/tag.ts` — 新建:标签 Pinia Store
|
||||||
|
- `client/src/stores/stats.ts` — 修改:fetchCategoryStats/fetchTrend 支持 period
|
||||||
|
- `client/src/components/DragSortList/DragSortList.vue` — 新建:拖拽排序组件
|
||||||
|
- `client/src/pages/tag-manage/index.vue` — 新建:标签管理页面
|
||||||
|
- `client/src/pages/backup-manage/index.vue` — 新建:备份管理页面
|
||||||
|
- `client/src/pages/data-import/index.vue` — 新建:数据导入页面
|
||||||
|
- `client/src/pages.json` — 注册新页面路由
|
||||||
|
|
||||||
|
**依赖任务**:T01(需知 API 端点格式)
|
||||||
|
|
||||||
|
**优先级**:P1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### T04: 前端现有页面改造
|
||||||
|
|
||||||
|
**描述**:改造现有页面以集成新功能——分类拖拽、反馈列表、统计维度切换、标签入口、服务端导出、健康检查卡片。
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `client/src/pages/category-manage/index.vue` — 集成 DragSortList 拖拽排序
|
||||||
|
- `client/src/pages/feedback/index.vue` — 新增"我的反馈"Tab
|
||||||
|
- `client/src/pages/stats/index.vue` — 添加维度切换器(周/月/年)+ 标签统计入口
|
||||||
|
- `client/src/pages/profile/index.vue` — 导出面板增加"服务端导出"选项
|
||||||
|
- `client/src/pages/add/index.vue` — 添加标签选择入口
|
||||||
|
- `client/src/pages/bills/index.vue` — 添加标签筛选功能
|
||||||
|
- `client/src/pages/admin/index.vue` — 添加服务状态卡片(30s 自动刷新)
|
||||||
|
|
||||||
|
**依赖任务**:T03(依赖 API 层和组件)
|
||||||
|
|
||||||
|
**优先级**:P1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### T05: 集成联调 + 边界处理
|
||||||
|
|
||||||
|
**描述**:前后端联调、边界场景处理(导入去重、大量数据导出、拖拽边界)、最终测试修复。
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- 可能涉及 T01~T04 中所有文件的微调
|
||||||
|
- 重点:`server/src/routes/export.ts`(大量数据流式导出稳定性)
|
||||||
|
- 重点:`server/src/routes/transaction.ts`(导入去重逻辑)
|
||||||
|
- 重点:`client/src/components/DragSortList/DragSortList.vue`(拖拽边界场景)
|
||||||
|
|
||||||
|
**依赖任务**:T04
|
||||||
|
|
||||||
|
**优先级**:P2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 共享知识(跨文件约定)
|
||||||
|
|
||||||
|
```
|
||||||
|
- API 响应格式统一:{ code: 0, data: T } 成功;{ code: 5位数字, message: string } 失败
|
||||||
|
- 错误码约定:
|
||||||
|
- 40001: 参数无效
|
||||||
|
- 40002: 类型无效
|
||||||
|
- 40100: 未登录
|
||||||
|
- 40101: token过期
|
||||||
|
- 40300: 无权限
|
||||||
|
- 40400: 资源不存在
|
||||||
|
- 42900: 请求频繁
|
||||||
|
- 50000: 服务器内部错误
|
||||||
|
- 金额单位统一为"分"(INT),前端展示时 / 100
|
||||||
|
- 日期格式统一为 YYYY-MM-DD,月份为 YYYY-MM
|
||||||
|
- 认证使用 HMAC-SHA256 Token,Bearer 格式,30天有效期
|
||||||
|
- 公开路径(无需认证):/api/auth/login, /api/auth/demo-login, /api/health, /api/backup/:id/download(token 校验)
|
||||||
|
- 前端 request() 函数自动处理 401 重登录、错误 toast
|
||||||
|
- Pinia Store 方法命名:fetch* (获取数据)、add/create (新增)、update (更新)、delete (删除)
|
||||||
|
- 前端 API 函数命名:get* (GET), create*/submit* (POST), update*/sort* (PUT), delete* (DELETE)
|
||||||
|
- SCSS 变量:$primary=#FF8C69, $success=#7BC67E, $danger=#FF6B6B, $text=#2D1B1B, $surface=#FFFFFF, $bg=#FFF8F0
|
||||||
|
- 组件样式使用 scoped + @import '@/styles/mixins.scss'
|
||||||
|
- 标签预设 8 色:#FF8C69, #7BC67E, #5B9BD5, #FFD700, #FF69B4, #8B5CF6, #F97316, #06B6D4
|
||||||
|
- 每笔交易最多 5 个标签,每用户最多 20 个标签
|
||||||
|
- 导入文件限制 5MB,导出流式响应超时 60s
|
||||||
|
- 分页参数:page (从1开始), pageSize (默认20, 最大100)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 任务依赖图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
T01[T01: 后端基础设施+DB迁移] --> T02[T02: 后端API改造]
|
||||||
|
T01 --> T03[T03: 前端API+Store+新页面]
|
||||||
|
T02 --> T04[T04: 前端现有页面改造]
|
||||||
|
T03 --> T04
|
||||||
|
T04 --> T05[T05: 集成联调+边界处理]
|
||||||
|
```
|
||||||
55
docs/class-diagram.mermaid
Normal file
55
docs/class-diagram.mermaid
Normal file
@@ -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 个标签"
|
||||||
254
docs/prd-alignment.md
Normal file
254
docs/prd-alignment.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# 小菜记账 — 前后端对齐增量 PRD
|
||||||
|
|
||||||
|
> 版本: v1.0 | 日期: 2026-07-11
|
||||||
|
> 类型: 增量 PRD(仅包含变更部分)
|
||||||
|
> 基线: 前后端对齐分析发现的 8 项差距
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、产品目标
|
||||||
|
|
||||||
|
| # | 目标 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| G1 | 消灭前后端差距,实现已开发后端能力的前端落地 | 后端已有排序、备份、健康检查等端点,但前端缺失对应 UI |
|
||||||
|
| G2 | 补齐用户反馈闭环,提升产品可信度 | 用户提交反馈后无法查看回复,体验断裂 |
|
||||||
|
| G3 | 扩展数据维度与吞吐能力 | 支持年度/周度统计、服务端导出、数据导入,为数据量增长做准备 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、用户故事
|
||||||
|
|
||||||
|
### P0 — 分类拖拽排序
|
||||||
|
|
||||||
|
> As a 用户, I want 在分类管理页面拖拽调整分类顺序, so that 常用分类排在前面,记账时选得更快。
|
||||||
|
|
||||||
|
### P1 — 反馈回复展示
|
||||||
|
|
||||||
|
> As a 用户, I want 查看自己提交的反馈列表及管理员回复, so that 我知道反馈是否被处理以及处理结果。
|
||||||
|
|
||||||
|
### P1 — 备份管理页面
|
||||||
|
|
||||||
|
> As a 管理员, I want 在前端触发备份、查看备份列表并下载备份文件, so that 我可以在出现问题时快速恢复数据。
|
||||||
|
|
||||||
|
### P2 — 统计年度/周视图
|
||||||
|
|
||||||
|
> As a 用户, I want 在统计页面切换年/周/月维度, so that 我可以从不同时间粒度分析收支趋势。
|
||||||
|
|
||||||
|
### P2 — 数据导入与服务端导出
|
||||||
|
|
||||||
|
> As a 用户, I want 导入 JSON 格式的交易数据, so that 我可以从其他记账工具迁移历史数据。
|
||||||
|
>
|
||||||
|
> As a 用户, I want 大数据量导出走服务端生成, so that 导出速度快、不卡顿。
|
||||||
|
|
||||||
|
### P3 — 健康检查
|
||||||
|
|
||||||
|
> As a 管理员, I want 在管理页面看到后端服务运行状态, so that 我能第一时间发现服务异常。
|
||||||
|
|
||||||
|
### P3 — 交易标签系统
|
||||||
|
|
||||||
|
> As a 用户, I want 给交易添加标签(如"出差""聚餐"), so that 我可以按标签筛选和统计关联交易。
|
||||||
|
>
|
||||||
|
> As a 用户, I want 按标签筛选交易列表和查看标签维度的统计, so that 我可以追踪特定场景的收支。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、需求池
|
||||||
|
|
||||||
|
### P0 — Must Have
|
||||||
|
|
||||||
|
| ID | 需求 | 前端 | 后端 | 验收标准 |
|
||||||
|
|----|------|------|------|----------|
|
||||||
|
| P0-1 | 分类拖拽排序 | 分类管理页面添加拖拽排序 UI,拖拽结束后调用 `sortCategories(ids)` | 无新增(`PUT /categories/sort` 已实现) | 拖拽后顺序持久化;刷新页面顺序保持;支出/收入分类各自排序 |
|
||||||
|
|
||||||
|
### P1 — Should Have
|
||||||
|
|
||||||
|
| ID | 需求 | 前端 | 后端 | 验收标准 |
|
||||||
|
|----|------|------|------|----------|
|
||||||
|
| P1-1 | 用户反馈列表 | 反馈页面新增"我的反馈"Tab,展示反馈列表及管理员回复 | 新增 `GET /feedback/mine` 端点(用户维度,返回当前用户的反馈+`admin_reply`) | 用户只能看自己的反馈;未回复显示"待处理"状态;已回复显示回复内容和时间 |
|
||||||
|
| P1-2 | 备份管理页面 | 新增备份管理页面:触发备份按钮、备份列表、下载按钮 | 新增 `GET /backup/:id/download` 端点 | 管理员可触发备份并看到进度;列表显示备份时间和大小;点击下载获取备份文件 |
|
||||||
|
|
||||||
|
### P2 — Nice to Have
|
||||||
|
|
||||||
|
| ID | 需求 | 前端 | 后端 | 验收标准 |
|
||||||
|
|----|------|------|------|----------|
|
||||||
|
| P2-1 | 统计年度/周视图 | 统计页面添加维度切换器(周/月/年),切换后图表和排行联动更新 | `GET /stats/category` 和 `GET /stats/trend` 支持 `period=week|month|year` 参数,按对应维度聚合数据 | 周视图显示本周每日数据;年视图显示每月汇总;切换时图表平滑过渡 |
|
||||||
|
| P2-2 | 数据导入 | 我的页面新增"数据导入"入口;支持 JSON 文件上传;导入前预览数据条数,确认后提交 | 新增 `POST /api/transactions/import` 端点,接收 JSON 文件,校验格式,批量写入 | 支持 JSON 格式;导入前展示条数预览;重复数据(同日期/金额/分类)跳过并提示;导入完成显示成功/跳过计数 |
|
||||||
|
| P2-3 | 数据导出服务端化 | 数据导出页面新增"服务端导出"选项;大数据量(>1000条)自动走服务端 | 新增 `GET /api/export` 端点,支持 CSV/JSON 格式,流式响应 | 客户端导出保留作为轻量选项;服务端导出无条数限制;下载进度可感知 |
|
||||||
|
|
||||||
|
### P3 — Nice to Have(远期)
|
||||||
|
|
||||||
|
| ID | 需求 | 前端 | 后端 | 验收标准 |
|
||||||
|
|----|------|------|------|----------|
|
||||||
|
| P3-1 | 健康检查展示 | 管理页面新增服务状态卡片,显示后端健康信息 | 将 `/api/health` 端点移到 authMiddleware 之前 | 管理页面实时显示服务状态(正常/异常);无需登录即可调用健康检查 |
|
||||||
|
| P3-2 | 交易标签 — 标签管理 | 新增标签管理页面:创建/编辑/删除标签(名称+颜色) | 新增 `tags` 表及 CRUD 端点(`GET/POST/PUT/DELETE /api/tags`) | 每个用户独立标签;标签不可重名;删除标签时解绑关联交易 |
|
||||||
|
| P3-3 | 交易标签 — 交易关联 | 记账页面新增"添加标签"入口;交易详情/编辑页可修改标签 | `transactions` 表新增关联;`POST/PUT /transactions` 支持 `tagIds` 字段 | 一笔交易可关联多个标签;标签选择器展示用户已有标签 |
|
||||||
|
| P3-4 | 交易标签 — 标签筛选统计 | 账单页支持按标签筛选;统计页新增标签维度视图 | `GET /transactions` 支持 `tagId` 筛选参数;`GET /stats/by-tag` 新端点 | 筛选结果仅显示含指定标签的交易;标签统计展示各标签下收支总额 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、UI 交互说明
|
||||||
|
|
||||||
|
### P0-1 分类拖拽排序
|
||||||
|
|
||||||
|
```
|
||||||
|
分类管理页面(category-manage/index.vue)变更:
|
||||||
|
|
||||||
|
1. 页面顶部新增提示栏:"长按拖拽可调整分类顺序"
|
||||||
|
2. 每个分类项左侧显示拖拽手柄图标(Lucide GripVertical, #BFB3B3)
|
||||||
|
3. 长按分类项(300ms)进入拖拽模式:
|
||||||
|
- 被拖拽项 elevation 提升(shadow-clay-float)
|
||||||
|
- 背景色变为 #FFF0E6
|
||||||
|
- 其余项轻微缩进让出空间
|
||||||
|
4. 拖拽过程中实时交换位置(带 150ms 过渡动画)
|
||||||
|
5. 松手后:
|
||||||
|
- 调用 sortCategories(ids) 提交新顺序
|
||||||
|
- 成功:轻量 toast "排序已保存"
|
||||||
|
- 失败:回滚到原位置 + 错误提示
|
||||||
|
6. 支出/收入 Tab 各自独立排序,互不影响
|
||||||
|
```
|
||||||
|
|
||||||
|
### P1-1 用户反馈列表
|
||||||
|
|
||||||
|
```
|
||||||
|
反馈页面(feedback/index.vue)变更:
|
||||||
|
|
||||||
|
1. 页面顶部新增 Tab 切换:"提交反馈" | "我的反馈"
|
||||||
|
2. "我的反馈" Tab 内容:
|
||||||
|
├── 列表按提交时间倒序排列
|
||||||
|
├── 每项显示:
|
||||||
|
│ ├── 反馈内容(截断2行,点击展开)
|
||||||
|
│ ├── 提交时间(相对时间:"3天前")
|
||||||
|
│ ├── 状态标签:
|
||||||
|
│ │ ├── 待处理 → 灰色 #BFB3B3
|
||||||
|
│ │ ├── 已回复 → 绿色 #7BC67E
|
||||||
|
│ │ └── 已关闭 → 橙色 #FF8C69
|
||||||
|
│ └── 管理员回复区域(仅已回复时显示):
|
||||||
|
│ ├── 分割线 + "管理员回复" 标签
|
||||||
|
│ ├── 回复内容
|
||||||
|
│ └── 回复时间
|
||||||
|
└── 空状态:插画 + "还没有提交过反馈"
|
||||||
|
3. 下拉刷新更新列表
|
||||||
|
4. 上拉加载更多(分页)
|
||||||
|
```
|
||||||
|
|
||||||
|
### P1-2 备份管理页面
|
||||||
|
|
||||||
|
```
|
||||||
|
新增页面(backup-manage/index.vue):
|
||||||
|
|
||||||
|
1. 顶部操作区:
|
||||||
|
├── [立即备份] 主按钮(Clay 风格)
|
||||||
|
├── 点击后按钮变为 loading 态
|
||||||
|
└── 成功后列表顶部新增一条记录 + toast "备份成功"
|
||||||
|
2. 备份列表:
|
||||||
|
├── 按时间倒序
|
||||||
|
├── 每项显示:
|
||||||
|
│ ├── 备份时间(2026-07-11 14:30)
|
||||||
|
│ ├── 文件大小(12.3 MB)
|
||||||
|
│ └── [下载] 按钮(Lucide Download 图标)
|
||||||
|
├── 点击下载:
|
||||||
|
│ ├── 按钮变为进度条
|
||||||
|
│ └── 完成后 toast "下载完成"
|
||||||
|
└── 空状态:"暂无备份记录"
|
||||||
|
3. 仅管理员角色可见此页面入口
|
||||||
|
```
|
||||||
|
|
||||||
|
### P2-1 统计年度/周视图
|
||||||
|
|
||||||
|
```
|
||||||
|
统计页面(stats/index.vue)变更:
|
||||||
|
|
||||||
|
1. 月份选择器升级为维度切换器:
|
||||||
|
├── 新增三个 Tab:周 | 月 | 年
|
||||||
|
├── 样式同现有支出/收入切换器
|
||||||
|
└── 切换后日期选择器联动:
|
||||||
|
├── 周:显示 "2026年7月 第2周",左右切换周
|
||||||
|
├── 月:保持现有行为
|
||||||
|
└── 年:显示 "2026年",左右切换年
|
||||||
|
2. 图表联动更新:
|
||||||
|
├── 周维度:趋势图 X 轴为周一~周日
|
||||||
|
├── 年维度:趋势图 X 轴为1月~12月
|
||||||
|
└── 环形图和排行同步更新为对应维度数据
|
||||||
|
3. 概览卡片数值联动
|
||||||
|
```
|
||||||
|
|
||||||
|
### P2-2 & P2-3 数据导入与导出
|
||||||
|
|
||||||
|
```
|
||||||
|
我的页面变更 + 新增页面:
|
||||||
|
|
||||||
|
1. "数据导出"入口变更:
|
||||||
|
├── 点击后进入导出页面
|
||||||
|
├── 导出方式选择:
|
||||||
|
│ ├── 客户端导出(< 1000 条推荐)— 现有逻辑
|
||||||
|
│ └── 服务端导出(≥ 1000 条推荐)— 新增
|
||||||
|
├── 格式选择:CSV | JSON
|
||||||
|
├── 日期范围选择器
|
||||||
|
└── [开始导出] 按钮
|
||||||
|
2. 新增"数据导入"入口(我的页面功能列表):
|
||||||
|
├── 点击后进入导入页面
|
||||||
|
├── 步骤流程:
|
||||||
|
│ ├── Step 1:选择文件(仅 .json)
|
||||||
|
│ ├── Step 2:预览 — 显示识别条数、日期范围、分类映射
|
||||||
|
│ ├── Step 3:确认导入
|
||||||
|
│ └── 结果:成功 N 条 / 跳过 M 条
|
||||||
|
└── 导入模板下载链接
|
||||||
|
```
|
||||||
|
|
||||||
|
### P3-1 健康检查展示
|
||||||
|
|
||||||
|
```
|
||||||
|
管理页面变更:
|
||||||
|
|
||||||
|
1. 页面顶部新增服务状态卡片:
|
||||||
|
├── 正常:绿色圆点 + "服务运行正常" + 响应时间 "23ms"
|
||||||
|
├── 异常:红色圆点 + "服务异常" + 错误信息
|
||||||
|
└── 检测中:灰色圆点 + 旋转图标
|
||||||
|
2. 每 30 秒自动刷新一次
|
||||||
|
3. 点击卡片可手动刷新
|
||||||
|
```
|
||||||
|
|
||||||
|
### P3-2~4 交易标签系统
|
||||||
|
|
||||||
|
```
|
||||||
|
全局变更:
|
||||||
|
|
||||||
|
1. 记账页面(add/index.vue):
|
||||||
|
├── 附加信息区新增"添加标签"行
|
||||||
|
├── 点击弹出标签选择器(底部弹窗):
|
||||||
|
│ ├── 已有标签列表(可多选)
|
||||||
|
│ ├── 搜索框
|
||||||
|
│ └── [新建标签] 快捷入口
|
||||||
|
└── 已选标签以药丸标签形式展示在输入区
|
||||||
|
2. 账单页面(bills/index.vue):
|
||||||
|
├── 筛选栏新增标签筛选
|
||||||
|
└── 筛选弹窗中增加标签选择
|
||||||
|
3. 统计页面(stats/index.vue):
|
||||||
|
└── 新增"标签统计"卡片(环形图 + 排行)
|
||||||
|
4. 我的页面:
|
||||||
|
└── 功能列表新增"标签管理"入口
|
||||||
|
5. 标签管理页面(tag-manage/index.vue)— 新增:
|
||||||
|
├── 标签列表(名称 + 颜色色块 + 关联交易数)
|
||||||
|
├── [新建标签] 按钮
|
||||||
|
├── 编辑弹窗:名称输入 + 颜色选择器(8色预设)
|
||||||
|
└── 删除确认:关联 N 笔交易时提示"将解除关联"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、待确认问题
|
||||||
|
|
||||||
|
| # | 问题 | 影响范围 | 建议 |
|
||||||
|
|---|------|----------|------|
|
||||||
|
| Q1 | 分类拖拽排序是否需要动画反馈?拖拽手柄位置(左侧还是右侧)? | P0-1 | 建议左侧手柄 + 长按触发,符合主流交互习惯 |
|
||||||
|
| Q2 | 用户反馈列表是否需要通知推送?当管理员回复后用户是否收到微信服务通知? | P1-1 | 后端已有通知逻辑,但微信订阅消息需用户主动订阅,建议先做页面内展示,通知作为后续增强 |
|
||||||
|
| Q3 | 备份文件的下载鉴权方式?URL 是否需要时效性签名? | P1-2 | 建议使用临时签名 URL(有效期 1 小时),避免备份文件被猜测 URL 直接访问 |
|
||||||
|
| Q4 | 统计年度视图的趋势图粒度:按月汇总还是按季汇总? | P2-1 | 建议按月汇总(12 个点),季度可通过后续分组实现 |
|
||||||
|
| Q5 | 数据导入的 JSON 格式规范?是否需要支持其他记账 App 的导出格式? | P2-2 | 建议先定义小菜记账自有 JSON 格式,提供模板下载;其他 App 格式兼容作为远期需求 |
|
||||||
|
| Q6 | 服务端导出的文件存储方式:内存生成直接流式返回 vs 先写文件再下载? | P2-3 | 建议 < 1 万条内存流式返回,≥ 1 万条先写临时文件再下载,避免内存溢出 |
|
||||||
|
| Q7 | 交易标签上限:每笔交易最多关联几个标签?每个用户最多创建多少个标签? | P3-2~4 | 建议每笔交易最多 5 个标签,每用户最多 20 个标签,后续可调整 |
|
||||||
|
| Q8 | 标签与分类的关系:标签是否可以跨分类?同一标签能否同时用于支出和收入? | P3-2~4 | 建议标签不区分收支类型,跨分类使用,这是标签区别于分类的核心价值 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档版本: v1.0 | 最后更新: 2026-07-11*
|
||||||
53
docs/sequence-diagram.mermaid
Normal file
53
docs/sequence-diagram.mermaid
Normal file
@@ -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: 成功
|
||||||
@@ -277,6 +277,40 @@ async function runMigrations(conn: mysql.Connection) {
|
|||||||
console.log('[DB] Migrating: adding recurring_id to transactions')
|
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")
|
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() {
|
export async function initDatabase() {
|
||||||
|
|||||||
@@ -170,3 +170,23 @@ CREATE TABLE IF NOT EXISTS recurring_templates (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import configRoutes from './routes/config'
|
|||||||
import trackRoutes from './routes/track'
|
import trackRoutes from './routes/track'
|
||||||
import logsRoutes from './routes/logs'
|
import logsRoutes from './routes/logs'
|
||||||
import recurringRoutes from './routes/recurring'
|
import recurringRoutes from './routes/recurring'
|
||||||
|
import tagRoutes from './routes/tag'
|
||||||
|
import exportRoutes from './routes/export'
|
||||||
import { backupDatabase } from './utils/backup'
|
import { backupDatabase } from './utils/backup'
|
||||||
|
|
||||||
// Warn if token secret is using default fallback
|
// Warn if token secret is using default fallback
|
||||||
@@ -50,7 +52,7 @@ app.use(cors({
|
|||||||
},
|
},
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}))
|
}))
|
||||||
app.use(express.json({ limit: '10kb' }))
|
app.use(express.json({ limit: '5mb' }))
|
||||||
|
|
||||||
// 请求日志
|
// 请求日志
|
||||||
app.use(requestLogger)
|
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(authMiddleware)
|
||||||
|
|
||||||
app.use('/api/auth', authLimiter, authRoutes)
|
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/track', apiLimiter, trackRoutes)
|
||||||
app.use('/api/logs', apiLimiter, logsRoutes)
|
app.use('/api/logs', apiLimiter, logsRoutes)
|
||||||
app.use('/api/recurring', apiLimiter, recurringRoutes)
|
app.use('/api/recurring', apiLimiter, recurringRoutes)
|
||||||
|
app.use('/api/tags', apiLimiter, tagRoutes)
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.use('/api/export', apiLimiter, exportRoutes)
|
||||||
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((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
|||||||
@@ -2,11 +2,74 @@ import { Router, Response } from 'express'
|
|||||||
import { AuthRequest } from '../middleware/auth'
|
import { AuthRequest } from '../middleware/auth'
|
||||||
import { backupDatabase, getBackupList } from '../utils/backup'
|
import { backupDatabase, getBackupList } from '../utils/backup'
|
||||||
import { requireAdmin } from '../middleware/requireAdmin'
|
import { requireAdmin } from '../middleware/requireAdmin'
|
||||||
|
import { createHmac } from 'crypto'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret'
|
||||||
|
|
||||||
// 所有路由都需要管理员权限
|
// 所有路由都需要管理员权限(除了 :id/download 使用签名校验)
|
||||||
router.use((req, res, next) => requireAdmin(req, res, next))
|
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) => {
|
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) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const list = getBackupList()
|
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) {
|
} catch (err) {
|
||||||
console.error('[Backup] 获取列表失败:', err)
|
console.error('[Backup] 获取列表失败:', err)
|
||||||
res.status(500).json({ code: 50000, message: '获取备份列表失败' })
|
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
|
export default router
|
||||||
|
|||||||
231
server/src/routes/export.ts
Normal file
231
server/src/routes/export.ts
Normal file
@@ -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<void>((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
|
||||||
@@ -5,6 +5,36 @@ import { requireAdmin } from '../middleware/requireAdmin'
|
|||||||
|
|
||||||
const router = Router()
|
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) => {
|
router.post('/', authMiddleware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -52,15 +82,15 @@ router.get('/', requireAdmin, async (req, res) => {
|
|||||||
)
|
)
|
||||||
const total = (countResult as any[])[0].total
|
const total = (countResult as any[])[0].total
|
||||||
|
|
||||||
// LIMIT/OFFSET 直接嵌入 SQL(execute 对参数类型要求严格)
|
// LIMIT/OFFSET 使用参数化查询
|
||||||
const [rows] = await pool.execute(
|
const [rows] = await pool.execute(
|
||||||
`SELECT f.*, u.nickname, u.avatar_url
|
`SELECT f.*, u.nickname, u.avatar_url
|
||||||
FROM feedbacks f
|
FROM feedbacks f
|
||||||
LEFT JOIN users u ON f.user_id = u.id
|
LEFT JOIN users u ON f.user_id = u.id
|
||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
LIMIT ${pageSize} OFFSET ${offset}`,
|
LIMIT ? OFFSET ?`,
|
||||||
params
|
[...params, pageSize, offset]
|
||||||
)
|
)
|
||||||
|
|
||||||
res.json({ code: 0, data: { list: rows, total, page, pageSize } })
|
res.json({ code: 0, data: { list: rows, total, page, pageSize } })
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getCurrentMonth, getMonthRange } from '../utils/date'
|
|||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
const VALID_TYPES = ['expense', 'income']
|
const VALID_TYPES = ['expense', 'income']
|
||||||
|
const VALID_PERIODS = ['week', 'month', 'year']
|
||||||
|
|
||||||
/** 根据 group_id 参数构建 WHERE 条件(含成员验证) */
|
/** 根据 group_id 参数构建 WHERE 条件(含成员验证) */
|
||||||
async function buildWhereClause(
|
async function buildWhereClause(
|
||||||
@@ -103,14 +104,40 @@ router.get('/overview', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
router.get('/category', async (req: AuthRequest, res: Response) => {
|
router.get('/category', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
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)) {
|
if (!VALID_TYPES.includes(type as string)) {
|
||||||
return res.status(400).json({ code: 40002, message: '类型无效' })
|
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 m = (month as string) || getCurrentMonth()
|
||||||
|
|
||||||
|
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)
|
const range = getMonthRange(m)
|
||||||
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||||
const { startDate, endDate } = range
|
startDate = range.startDate
|
||||||
|
endDate = range.endDate
|
||||||
|
}
|
||||||
|
|
||||||
const result = await buildWhereClause(
|
const result = await buildWhereClause(
|
||||||
req.userId,
|
req.userId,
|
||||||
@@ -138,14 +165,57 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
router.get('/trend', async (req: AuthRequest, res: Response) => {
|
router.get('/trend', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
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)) {
|
if (!VALID_TYPES.includes(type as string)) {
|
||||||
return res.status(400).json({ code: 40002, message: '类型无效' })
|
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 m = (month as string) || getCurrentMonth()
|
||||||
|
|
||||||
|
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)
|
const range = getMonthRange(m)
|
||||||
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||||
const { startDate, endDate } = range
|
startDate = range.startDate
|
||||||
|
endDate = range.endDate
|
||||||
|
groupBy = 't.date'
|
||||||
|
labelExpr = `CONCAT(DAY(t.date), '日')`
|
||||||
|
}
|
||||||
|
|
||||||
const result = await buildWhereClause(
|
const result = await buildWhereClause(
|
||||||
req.userId,
|
req.userId,
|
||||||
@@ -155,10 +225,10 @@ router.get('/trend', async (req: AuthRequest, res: Response) => {
|
|||||||
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||||
|
|
||||||
const [rows] = await pool.query(
|
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
|
FROM transactions t
|
||||||
${result.where}
|
${result.where}
|
||||||
GROUP BY t.date
|
GROUP BY ${groupBy}
|
||||||
ORDER BY t.date`,
|
ORDER BY t.date`,
|
||||||
[...result.params, type, startDate, endDate]
|
[...result.params, type, startDate, endDate]
|
||||||
)
|
)
|
||||||
|
|||||||
216
server/src/routes/tag.ts
Normal file
216
server/src/routes/tag.ts
Normal file
@@ -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
|
||||||
@@ -33,6 +33,17 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
|||||||
if (!tx) {
|
if (!tx) {
|
||||||
return res.status(404).json({ code: 40400, message: '记录不存在' })
|
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 })
|
res.json({ code: 0, data: tx })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Transaction] GET by ID error:', 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) => {
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
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 pSize = safeInt(pageSize, 20, 1, 100)
|
||||||
const pNum = safeInt(page, 1, 1, 99999)
|
const pNum = safeInt(page, 1, 1, 99999)
|
||||||
const offset = (pNum - 1) * pSize
|
const offset = (pNum - 1) * pSize
|
||||||
@@ -105,6 +116,17 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
where += ' AND t.note LIKE ?'; params.push(`%${escapedKeyword}%`)
|
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 orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
|
||||||
|
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
@@ -114,6 +136,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN categories c ON t.category_id = c.id
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
LEFT JOIN users u ON t.user_id = u.id
|
LEFT JOIN users u ON t.user_id = u.id
|
||||||
|
${tagJoin}
|
||||||
${where}
|
${where}
|
||||||
ORDER BY ${orderBy}
|
ORDER BY ${orderBy}
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -121,7 +144,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const [countResult] = await pool.query(
|
const [countResult] = await pool.query(
|
||||||
`SELECT COUNT(*) as total FROM transactions t ${where}`,
|
`SELECT COUNT(*) as total FROM transactions t ${tagJoin} ${where}`,
|
||||||
params
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,7 +153,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
`SELECT
|
`SELECT
|
||||||
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as totalExpense,
|
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
|
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
|
params
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -153,7 +176,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
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) {
|
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之间的正整数(单位:分)' })
|
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(
|
// 校验 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (?, ?, ?, ?, ?, ?, ?)',
|
'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]
|
[req.userId, amount, type, category_id || null, note || null, date, validGroupId]
|
||||||
)
|
)
|
||||||
|
|
||||||
res.json({ code: 0, data: { id: (result as any).insertId } })
|
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) {
|
} catch (err) {
|
||||||
console.error('[Transaction] POST error:', err)
|
console.error('[Transaction] POST error:', err)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
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<string>()
|
||||||
|
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<string, number>()
|
||||||
|
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) => {
|
router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
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) {
|
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之间的正整数(单位:分)' })
|
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) {
|
if (note && note.length > 200) {
|
||||||
return res.status(400).json({ code: 40004, message: '备注不能超过200字' })
|
return res.status(400).json({ code: 40004, message: '备注不能超过200字' })
|
||||||
}
|
}
|
||||||
const [result] = await pool.query(
|
|
||||||
|
// 校验 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 = ?',
|
'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]
|
[amount, type, category_id || null, note || null, date, req.params.id, req.userId]
|
||||||
)
|
)
|
||||||
if ((result as any).affectedRows === 0) {
|
if ((result as any).affectedRows === 0) {
|
||||||
|
await conn.rollback()
|
||||||
return res.status(404).json({ code: 40400, message: '记录不存在' })
|
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 })
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback()
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
conn.release()
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Transaction] PUT error:', err)
|
console.error('[Transaction] PUT error:', err)
|
||||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
|||||||
Reference in New Issue
Block a user