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:
@@ -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
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 })
|
||||
}
|
||||
|
||||
/** 我的反馈条目(简化版) */
|
||||
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
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 {
|
||||
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
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
|
||||
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 })
|
||||
}
|
||||
|
||||
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: {
|
||||
'#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(() => {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
|
||||
<!-- 标签筛选条 -->
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
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 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
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') {
|
||||
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
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 }
|
||||
})
|
||||
Reference in New Issue
Block a user