feat: 前后端功能对齐 - 实现8个对齐差距

P0-1: 分类拖拽排序 - category-manage 添加拖拽 UI
P1-1: 反馈回复展示 - 新增 GET /feedback/mine + 前端我的反馈Tab
P1-2: 备份管理页面 - 新增下载端点 + backup-manage 页面
P2-1: 统计年度/周视图 - stats 支持 period 参数 + 前端维度切换器
P2-2: 数据导入 - 新增 POST /import 端点 + data-import 页面
P2-3: 数据导出服务端化 - 新增 GET /export 流式端点
P3-1: 健康检查展示 - health 移到 auth 前 + admin 状态卡片
P3-2~4: 交易标签系统 - tags CRUD + 交易关联 + 按标签筛选统计

后端: 新增 tag.ts/export.ts 路由, 改造 feedback/backup/transaction/stats
前端: 新增 DragSortList 组件, 3个新页面, 改造 7 个现有页面
QA 修复: 5个严重Bug + 4个潜在问题
This commit is contained in:
2026-06-10 17:30:36 +08:00
parent 5df910c9f1
commit 31f6487d61
36 changed files with 4162 additions and 64 deletions

View File

@@ -54,3 +54,11 @@ export function updateUserRole(id: number, role: 'user' | 'admin') {
export function deleteUser(id: number) {
return request({ url: `/admin/users/${id}`, method: 'DELETE' })
}
// 健康检查类型从 @/api/health 导入
export type { HealthStatus } from '@/api/health'
/** 获取服务健康状态(管理员) */
export function getHealth() {
return request<import('./health').HealthStatus>({ url: '/health' })
}

38
client/src/api/backup.ts Normal file
View 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
View 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()}`
}

View File

@@ -29,6 +29,22 @@ export function getFeedbackList(params?: { page?: number; pageSize?: number; sta
return request<{ list: Feedback[]; total: number; page: number; pageSize: number }>({ url: '/feedback', data: params })
}
/** 我的反馈条目(简化版) */
export interface MyFeedback {
id: number
type: string
content: string
contact: string
status: 'pending' | 'processed' | 'ignored'
admin_reply?: string
created_at: string
}
/** 获取我的反馈列表 */
export function getMyFeedbacks(params?: { page?: number; pageSize?: number }) {
return request<{ list: MyFeedback[]; total: number; page: number; pageSize: number }>({ url: '/feedback/mine', data: params })
}
/** 更新反馈状态(管理员) */
export function updateFeedbackStatus(id: number, status: Feedback['status'], admin_reply?: string) {
return request({ url: `/feedback/${id}/status`, method: 'PUT', data: { status, admin_reply } })

15
client/src/api/health.ts Normal file
View 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' })
}

View File

@@ -25,6 +25,7 @@ export interface CategoryStat {
export interface TrendPoint {
date: string
amount: number
label?: string
}
/** 获取概览数据 */
@@ -33,13 +34,13 @@ export function getOverview(params?: { month?: string; startDate?: string; endDa
}
/** 获取分类统计 */
export function getCategoryStats(month?: string, type: string = 'expense', group_id?: number | null) {
return request<CategoryStat[]>({ url: '/stats/category', data: { month, type, group_id } })
export function getCategoryStats(month?: string, type: string = 'expense', group_id?: number | null, period?: 'week' | 'month' | 'year') {
return request<CategoryStat[]>({ url: '/stats/category', data: { month, type, group_id, period } })
}
/** 获取趋势数据 */
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) {
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } })
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null, period?: 'week' | 'month' | 'year') {
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id, period } })
}
/** 智能分类建议结果 */

47
client/src/api/tag.ts Normal file
View 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 })
}

View File

@@ -15,6 +15,14 @@ export interface Transaction {
group_id?: number | null
creator_nickname?: string
creator_avatar?: string
tags?: TagItem[]
}
/** 标签简略信息 */
export interface TagItem {
id: number
name: string
color: string
}
/** 交易列表响应 */
@@ -40,6 +48,7 @@ export interface TransactionParams {
minAmount?: number
maxAmount?: number
keyword?: string
tagId?: number
}
/** 获取交易列表 */
@@ -60,6 +69,7 @@ export function createTransaction(data: {
note?: string
date: string
group_id?: number | null
tagIds?: number[]
}) {
return request<{ id: number }>({ url: '/transactions', method: 'POST', data })
}
@@ -71,6 +81,7 @@ export function updateTransaction(id: number, data: {
category_id?: number
note?: string
date: string
tagIds?: number[]
}) {
return request({ url: `/transactions/${id}`, method: 'PUT', data })
}
@@ -79,3 +90,16 @@ export function updateTransaction(id: number, data: {
export function deleteTransaction(id: number) {
return request({ url: `/transactions/${id}`, method: 'DELETE' })
}
/** 导入交易记录 */
export function importTransactions(data: {
transactions: Array<{
date: string
type: 'expense' | 'income'
category_name: string
amount: number
note?: string
}>
}) {
return request<{ imported: number; skipped: number }>({ url: '/transactions/import', method: 'POST', data })
}

View 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>

View File

@@ -50,6 +50,7 @@ const iconMap: Record<string, Record<string, string>> = {
},
check: {
'#FFFFFF': '/static/icons/check-white.png',
'#FF8C69': '/static/icons/check-orange.png',
},
trash: {
'#FF8C69': '/static/icons/trash-orange.png',
@@ -71,6 +72,25 @@ const iconMap: Record<string, Record<string, string>> = {
'#8B7E7E': '/static/icons/settings-gray.png',
'#FF8C69': '/static/icons/settings-orange.png',
},
tag: {
'#8B7E7E': '/static/icons/tag-gray.png',
'#FF8C69': '/static/icons/tag-orange.png',
'#BFB3B3': '/static/icons/tag-light.png',
},
archive: {
'#7BC67E': '/static/icons/archive-green.png',
'#BFB3B3': '/static/icons/archive-light.png',
},
download: {
'#5B9BD5': '/static/icons/download-blue.png',
},
upload: {
'#FF8C69': '/static/icons/upload-orange.png',
},
dragHandle: {
'#BFB3B3': '/static/icons/drag-handle-light.png',
'#8B7E7E': '/static/icons/drag-handle-gray.png',
},
}
const iconSrc = computed(() => {

View File

@@ -154,6 +154,29 @@
"navigationBarTitleText": "埋点统计",
"enablePullDownRefresh": true
}
},
{
"path": "pages/tag-manage/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "标签管理",
"enablePullDownRefresh": true
}
},
{
"path": "pages/backup-manage/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "数据备份",
"enablePullDownRefresh": true
}
},
{
"path": "pages/data-import/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "数据导入"
}
}
],
"globalStyle": {

View File

@@ -53,6 +53,18 @@
<Icon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="blurAmountEditor" />
</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>
@@ -64,6 +76,33 @@
@continue="onContinue"
@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>
</template>
@@ -72,6 +111,7 @@ import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useCategoryStore } from '@/stores/category'
import { useTransactionStore } from '@/stores/transaction'
import { useTagStore } from '@/stores/tag'
import { waitForReady } from '@/utils/app-ready'
import { suggestCategory } from '@/api/stats'
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
@@ -82,11 +122,14 @@ import { statusBarHeight, capsuleRight } from '@/utils/system'
const catStore = useCategoryStore()
const txStore = useTransactionStore()
const tagStore = useTagStore()
const type = ref<'expense' | 'income'>('expense')
const amountStr = ref('0')
const selectedCat = ref<number | null>(null)
const note = ref('')
const selectedTagIds = ref<number[]>([])
const showTagPicker = ref(false)
const suggestedCatId = ref<number | null>(null)
const suggestConfidence = ref(0)
let suggestTimer: ReturnType<typeof setTimeout> | null = null
@@ -173,6 +216,13 @@ onMounted(async () => {
}
}
// 加载标签列表
try {
await tagStore.fetchTags()
} catch {
// 标签加载失败不影响主流程
}
const pages = getCurrentPages()
const page = pages[pages.length - 1] as { options?: Record<string, string> }
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {
@@ -192,6 +242,7 @@ onMounted(async () => {
lastCatByType[existing.type] = existing.category_id
note.value = existing.note || ''
selectedDate.value = existing.date.slice(0, 10)
selectedTagIds.value = existing.tags?.map(t => t.id) || []
// 等待分类列表渲染完成后滚动到选中项
nextTick(() => {
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
@@ -211,7 +262,10 @@ onMounted(async () => {
// 返回页面时刷新分类(可能新增了分类)
const initialLoaded = ref(false)
onShow(() => {
if (initialLoaded.value) catStore.fetchCategories()
if (initialLoaded.value) {
catStore.fetchCategories()
tagStore.fetchTags()
}
})
function onDateChange(e: any) {
@@ -241,7 +295,8 @@ async function save() {
type: type.value,
category_id: selectedCat.value,
note: note.value,
date: selectedDate.value
date: selectedDate.value,
...(selectedTagIds.value.length > 0 ? { tagIds: selectedTagIds.value } : {})
}
if (editId.value) {
await txStore.updateTransaction(editId.value, data)
@@ -265,6 +320,7 @@ function onContinue() {
// 重置表单,保留分类和日期
amountStr.value = ''
note.value = ''
selectedTagIds.value = []
showSuccess.value = false
nextTick(() => amountEditorRef.value?.focus())
}
@@ -274,6 +330,28 @@ function onBack() {
uni.navigateBack()
}
function openTagPicker() {
blurAmountEditor()
showTagPicker.value = true
}
function toggleTag(tagId: number) {
const idx = selectedTagIds.value.indexOf(tagId)
if (idx >= 0) {
selectedTagIds.value = selectedTagIds.value.filter(id => id !== tagId)
} else {
if (selectedTagIds.value.length >= 5) {
uni.showToast({ title: '最多选择 5 个标签', icon: 'none' })
return
}
selectedTagIds.value = [...selectedTagIds.value, tagId]
}
}
function getTagById(id: number) {
return tagStore.getById(id)
}
function goBack() {
// 保存成功后直接返回,不弹确认框
if (showSuccess.value) return
@@ -461,6 +539,99 @@ function goBack() {
.placeholder { color: $text-muted; }
.tag-display {
flex: 1;
display: flex;
align-items: center;
min-width: 0;
}
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.tag-chip {
padding: 4rpx 16rpx;
border-radius: $radius-xs;
}
.tag-chip-text {
font-size: $font-sm;
font-weight: 500;
}
/* 标签选择弹窗 */
.modal-mask {
@include modal-mask;
}
.tag-modal {
@include bottom-modal;
padding-bottom: calc($space-xl + env(safe-area-inset-bottom));
}
.tag-modal-title {
@include modal-title;
text-align: left;
padding: $space-lg $space-xl $space-xs;
}
.tag-modal-hint {
font-size: $font-sm;
color: $text-muted;
padding: 0 $space-xl $space-md;
}
.tag-modal-list {
max-height: 500rpx;
padding: 0 $space-xl;
}
.tag-option {
display: flex;
align-items: center;
gap: $space-sm;
padding: $space-md 0;
border-bottom: 1rpx solid $border;
&:last-child { border-bottom: none; }
&:active { background: $surface-warm; }
}
.tag-option-dot {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
flex-shrink: 0;
}
.tag-option-name {
flex: 1;
font-size: $font-lg;
color: $text;
}
.tag-empty {
padding: $space-2xl 0;
text-align: center;
}
.tag-empty-text {
font-size: $font-md;
color: $text-muted;
}
.tag-modal-btns {
padding: $space-md $space-xl;
border-top: 2rpx solid $border;
}
.tag-modal-btn {
@include btn-primary;
width: 100%;
}
</style>

View File

@@ -59,6 +59,29 @@
</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="entry-list">
@@ -103,12 +126,15 @@ import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight } from '@/utils/system'
import { getDashboard } from '@/api/admin'
import { getHealth } from '@/api/health'
import type { HealthStatus } from '@/api/health'
import { formatAmount } from '@/utils/format'
import Icon from '@/components/Icon/Icon.vue'
import type { Dashboard } from '@/api/admin'
const loading = ref(true)
const dashboard = ref<Dashboard | null>(null)
const health = ref<HealthStatus | null>(null)
onMounted(async () => {
await waitForReady()
@@ -120,12 +146,20 @@ onMounted(async () => {
} finally {
loading.value = false
}
// 加载健康状态(不阻塞页面)
try {
health.value = await getHealth()
} catch {
// 健康检查失败不影响主流程
}
})
// 静默刷新
async function refreshDashboard() {
try {
dashboard.value = await getDashboard()
health.value = await getHealth()
} catch {}
}
@@ -146,6 +180,16 @@ function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) }
function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
function goLogs() { uni.navigateTo({ url: '/pages/admin/logs' }) }
function goTrack() { uni.navigateTo({ url: '/pages/admin/track' }) }
function formatUptime(seconds: number): string {
if (!seconds) return '-'
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}${hours}小时`
if (hours > 0) return `${hours}小时${mins}`
return `${mins}分钟`
}
</script>
<style lang="scss" scoped>
@@ -299,4 +343,58 @@ function goTrack() { uni.navigateTo({ url: '/pages/admin/track' }) }
font-weight: 500;
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>

View 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>

View File

@@ -17,6 +17,25 @@
</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">
<text class="filter-stats-text"> {{ total }} </text>
@@ -43,6 +62,8 @@
:hideDate="true"
:showCreator="groupStore.isGroupMode"
:currentUserId="userStore.userInfo?.id"
:disableSwipe="groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id"
:swipeHint="!swipeHintDismissed && isFirstDeletable(item)"
@item-tap="onEdit(item)"
@delete="onDelete(item)"
/>
@@ -91,6 +112,7 @@ import { ref, computed, onMounted } from 'vue'
import { onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
import { useTagStore } from '@/stores/tag'
import { waitForReady } from '@/utils/app-ready'
import { getTransactions, deleteTransaction } from '@/api/transaction'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
@@ -104,8 +126,10 @@ import type { FilterParams } from '@/api/filter'
const userStore = useUserStore()
const groupStore = useGroupStore()
const tagStore = useTagStore()
const loading = ref(false)
const filterType = ref('all')
const filterTagId = ref<number | null>(null)
const page = ref(1)
const pageSize = 20
const txList = ref<Transaction[]>([])
@@ -113,6 +137,35 @@ const total = ref(0)
const loadError = ref(false)
let loadSeq = 0 // 请求序号,防止并发覆盖
// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复
const swipeHintDismissed = ref(false)
const HINT_KEY = 'xiaocai_swipe_hint_shown'
function isFirstDeletable(item: Transaction): boolean {
if (swipeHintDismissed.value) return false
// 群组模式下只有自己的记录可删除
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) return false
// 找到第一个可删除的项
for (const group of groupedTx.value) {
for (const tx of group.items) {
if (groupStore.isGroupMode && tx.user_id !== userStore.userInfo?.id) continue
return tx.id === item.id
}
}
return false
}
// 动画播完后标记已显示
try {
if (uni.getStorageSync(HINT_KEY)) {
swipeHintDismissed.value = true
}
} catch {}
setTimeout(() => {
swipeHintDismissed.value = true
try { uni.setStorageSync(HINT_KEY, '1') } catch {}
}, 3000)
// 高级筛选
const showFilter = ref(false)
const advancedFilters = ref<FilterParams>({})
@@ -145,6 +198,7 @@ const initialLoaded = ref(false)
onMounted(async () => {
await waitForReady()
tagStore.fetchTags().catch(() => {})
loadTx(true).finally(() => { initialLoaded.value = true })
})
@@ -174,6 +228,7 @@ async function loadTx(reset = false, silent = false) {
if (af.minAmount) params.minAmount = af.minAmount
if (af.maxAmount) params.maxAmount = af.maxAmount
if (af.keyword) params.keyword = af.keyword
if (filterTagId.value) params.tagId = filterTagId.value
try {
loading.value = true
loadError.value = false
@@ -379,4 +434,51 @@ onPullDownRefresh(() => {
.state-text {
@include state-text;
}
/* 标签筛选条 */
.tag-filter-scroll {
white-space: nowrap;
padding: 0 $space-xl $space-sm;
}
.tag-filter-row {
display: inline-flex;
gap: $space-xs;
}
.tag-filter-chip {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: $space-xs $space-md;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
flex-shrink: 0;
transition: all $transition-normal;
&.active {
background: $primary-light;
border-color: $primary;
}
&:active { opacity: 0.7; }
}
.tag-filter-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
flex-shrink: 0;
}
.tag-filter-text {
font-size: $font-sm;
color: $text-sec;
.active & {
color: $primary;
font-weight: 500;
}
}
</style>

View File

@@ -19,7 +19,10 @@
</view>
<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)">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" />
<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 selectedColor = ref(colorOptions[0])
// 拖拽排序相关
let dragStartY = 0
let dragCurrentIndex = -1
let dragItemEl: any = null
// 编辑相关
const showEditModal = ref(false)
const editingCat = ref<Category | null>(null)
@@ -270,6 +278,52 @@ async function onMigrateConfirm() {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
/** 拖拽排序:开始 */
function onDragStart(e: any) {
dragStartY = e.touches[0].clientY
dragItemEl = e.currentTarget?.parentElement
if (dragItemEl) {
const id = Number(dragItemEl.dataset?.id)
const idx = currentCategories.value.findIndex(c => c.id === id)
dragCurrentIndex = idx
dragItemEl.style?.opacity && (dragItemEl.style.opacity = '0.6')
}
}
/** 拖拽排序:移动 */
function onDragMove(e: any) {
// 视觉反馈由 CSS transition 处理,这里仅用于阻止页面滚动
}
/** 拖拽排序:结束 */
function onDragEnd(e: any) {
if (dragItemEl) {
dragItemEl.style?.opacity && (dragItemEl.style.opacity = '1')
}
const endY = e.changedTouches[0].clientY
const delta = endY - dragStartY
const threshold = 50
if (Math.abs(delta) > threshold && dragCurrentIndex >= 0) {
const direction = delta > 0 ? 1 : -1
const newIndex = dragCurrentIndex + direction
const cats = [...currentCategories.value]
if (newIndex >= 0 && newIndex < cats.length) {
// 交换位置
const temp = cats[dragCurrentIndex]
cats[dragCurrentIndex] = cats[newIndex]
cats[newIndex] = temp
// 保存排序
const sortedIds = cats.map(c => c.id)
catStore.sortCategories(sortedIds).catch(() => {})
}
}
dragCurrentIndex = -1
dragItemEl = null
}
</script>
<style lang="scss" scoped>
@@ -337,6 +391,16 @@ async function onMigrateConfirm() {
&: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 {
display: flex;
align-items: center;

View 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">支持 CSVJSON 格式文件需小于 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>

View File

@@ -11,7 +11,15 @@
</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">
<text class="label">反馈类型</text>
@@ -59,13 +67,45 @@
<text class="tip">感谢您的反馈我们会认真处理每一条意见</text>
</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>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted, watch } from 'vue'
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'
const types = [
@@ -75,6 +115,7 @@ const types = [
{ label: '其他', value: 'other' }
]
const currentTab = ref<'submit' | 'mine'>('submit')
const form = reactive({
type: 'bug',
content: '',
@@ -83,6 +124,50 @@ const form = reactive({
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() {
if (submitting.value || !form.content.trim()) return
@@ -94,7 +179,12 @@ async function onSubmit() {
contact: form.contact.trim() || undefined
})
uni.showToast({ title: '提交成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
form.content = ''
form.contact = ''
// 刷新我的反馈列表
if (currentTab.value === 'mine') {
loadMyFeedbacks(true)
}
} catch (e: any) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
} finally {
@@ -105,6 +195,22 @@ async function onSubmit() {
function goBack() {
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>
<style lang="scss" scoped>
@@ -259,4 +365,121 @@ function goBack() {
color: $text-muted;
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>

View File

@@ -51,6 +51,10 @@
<text class="mi-label">分类管理</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</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">
<text class="mi-label">周期账单</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
@@ -59,6 +63,10 @@
<text class="mi-label">数据导出</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goDataImport">
<text class="mi-label">数据导入</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<view class="menu-card mt">
@@ -83,6 +91,10 @@
<text class="mi-label">管理后台</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goBackupManage">
<text class="mi-label">数据备份</text>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<!-- 关于弹窗 -->
@@ -142,6 +154,16 @@
</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">
<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 exportType = ref<'all' | 'expense' | 'income'>('all')
const exportFormat = ref<'csv' | 'json'>('csv')
const exportMode = ref<'local' | 'server'>('local')
const exportCount = ref(0)
const exportLoading = ref(false)
@@ -305,6 +328,10 @@ function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' })
}
function goTagManage() {
uni.navigateTo({ url: '/pages/tag-manage/index' })
}
function goRecurring() {
uni.navigateTo({ url: '/pages/recurring/index' })
}
@@ -317,6 +344,14 @@ function goFeedback() {
uni.navigateTo({ url: '/pages/feedback/index' })
}
function goDataImport() {
uni.navigateTo({ url: '/pages/data-import/index' })
}
function goBackupManage() {
uni.navigateTo({ url: '/pages/backup-manage/index' })
}
function goProfileEdit() {
uni.navigateTo({ url: '/pages/profile-edit/index' })
}
@@ -416,6 +451,49 @@ function getLocalDateStr(): string {
async function doExport() {
if (exportCount.value === 0 || exportLoading.value) return
// 服务端导出
if (exportMode.value === 'server') {
exportLoading.value = true
try {
const { getExportUrl } = await import('@/api/export')
const url = getExportUrl({
startDate: exportStartDate.value,
endDate: exportEndDate.value,
type: exportType.value,
format: exportFormat.value
})
// #ifdef H5
window.open(url, '_blank')
// #endif
// #ifdef MP-WEIXIN
uni.downloadFile({
url,
success: (res) => {
if (res.statusCode === 200) {
const ext = exportFormat.value === 'json' ? 'json' : 'csv'
uni.openDocument({
filePath: res.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => uni.showToast({ title: '打开失败', icon: 'none' })
})
}
},
fail: () => uni.showToast({ title: '下载失败', icon: 'none' })
})
// #endif
uni.showToast({ title: '导出成功', icon: 'success' })
} catch {
uni.showToast({ title: '导出失败', icon: 'none' })
} finally {
exportLoading.value = false
showExportPanel.value = false
}
return
}
// 本地导出
exportLoading.value = true
uni.showLoading({ title: '导出中 0%' })
try {
@@ -965,4 +1043,11 @@ function saveFile(content: string, fileName: string, mimeType: string) {
font-weight: 600;
color: $surface;
}
.export-hint {
font-size: $font-sm;
color: $text-muted;
display: block;
margin-top: $space-sm;
}
</style>

View File

@@ -22,6 +22,14 @@
</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="ov-item">
<text class="ov-label">{{ tabType === 'expense' ? '总支出' : '总收入' }}</text>
@@ -95,7 +103,7 @@
/>
<view class="trend-list">
<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" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
</view>
@@ -139,6 +147,7 @@ const configStore = useConfigStore()
const currentMonth = ref(getCurrentMonth())
const tabType = ref<'expense' | 'income'>('expense')
const period = ref<'week' | 'month' | 'year'>('month')
const { overview, categoryStats, trendData } = storeToRefs(statsStore)
const loading = ref(true)
const loadError = ref(false)
@@ -176,7 +185,7 @@ const categoryChartData = computed(() => {
const trendChartData = computed(() => {
if (trendData.value.length === 0) return null
return {
categories: trendData.value.map(p => p.date.slice(8) + '日'),
categories: trendData.value.map(p => p.label || (p.date.slice(8) + '日')),
series: [{
name: tabType.value === 'expense' ? '支出' : '收入',
data: trendData.value.map(p => p.amount / 100)
@@ -241,6 +250,8 @@ onShow(() => {
watch(currentMonth, () => loadData())
// tab 切换时只请求分类和趋势数据
watch(tabType, () => loadTabData())
// 周期切换时重新加载数据
watch(period, () => loadData())
async function loadData(silent = false) {
const seq = ++loadSeq
@@ -249,8 +260,8 @@ async function loadData(silent = false) {
loadError.value = false
await Promise.all([
statsStore.fetchOverview(currentMonth.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value),
statsStore.fetchTrend(currentMonth.value, tabType.value, period.value),
fetchMaxSingle(),
fetchPrevMonthData()
])
@@ -269,8 +280,8 @@ async function loadTabData() {
const seq = ++loadSeq
try {
await Promise.all([
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value, period.value),
statsStore.fetchTrend(currentMonth.value, tabType.value, period.value),
fetchMaxSingle()
])
if (seq !== loadSeq) return
@@ -339,6 +350,10 @@ function nextMonth() {
currentMonth.value = m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`
}
function switchPeriod(p: 'week' | 'month' | 'year') {
period.value = p
}
function getBarWidth(amount: number) {
const max = Math.max(...trendData.value.map(t => t.amount), 1)
return (amount / max) * 100
@@ -409,6 +424,35 @@ function getBarWidth(amount: number) {
}
}
.period-wrap {
display: flex;
justify-content: center;
margin-bottom: $space-lg;
}
.period-tabs {
display: inline-flex;
background: $surface-warm;
border-radius: $radius-lg;
padding: $space-xs;
gap: $space-xs;
}
.period-tab {
padding: $space-xs $space-lg;
border-radius: $radius-lg;
font-size: $font-md;
color: $text-sec;
transition: all $transition-normal;
&.active {
background: $surface;
color: $primary;
font-weight: 600;
box-shadow: $shadow-sm;
}
}
.overview-card, .chart-card {
@include card($radius-2xl);
margin: 0 $space-xl $space-lg;

View 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>

View File

@@ -36,8 +36,8 @@ export const useStatsStore = defineStore('stats', () => {
}
}
async function fetchCategoryStats(month?: string, type: string = 'expense') {
const data = await api.getCategoryStats(month || getCurrentMonth(), type, getGroupId())
async function fetchCategoryStats(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
const data = await api.getCategoryStats(month || getCurrentMonth(), type, getGroupId(), period)
categoryStats.value = Array.isArray(data) ? data.map(item => ({
...item,
amount: Number(item.amount) || 0,
@@ -45,8 +45,8 @@ export const useStatsStore = defineStore('stats', () => {
})) : []
}
async function fetchTrend(month?: string, type: string = 'expense') {
const data = await api.getTrend(month || getCurrentMonth(), type, getGroupId())
async function fetchTrend(month?: string, type: string = 'expense', period?: 'week' | 'month' | 'year') {
const data = await api.getTrend(month || getCurrentMonth(), type, getGroupId(), period)
trendData.value = Array.isArray(data) ? data.map(item => ({
...item,
amount: Number(item.amount) || 0

52
client/src/stores/tag.ts Normal file
View 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 }
})