diff --git a/client/src/api/admin.ts b/client/src/api/admin.ts index bc4c138..ed1ba44 100644 --- a/client/src/api/admin.ts +++ b/client/src/api/admin.ts @@ -50,9 +50,13 @@ export function updateUserRole(id: number, role: 'user' | 'admin') { return request({ url: `/admin/users/${id}/status`, method: 'PUT', data: { role } }) } +export interface DeleteUserOptions { + confirmName: string +} + /** 删除用户 */ -export function deleteUser(id: number) { - return request({ url: `/admin/users/${id}`, method: 'DELETE' }) +export function deleteUser(id: number, options: DeleteUserOptions) { + return request({ url: `/admin/users/${id}`, method: 'DELETE', data: options }) } // 健康检查类型从 @/api/health 导入 diff --git a/client/src/api/tag.ts b/client/src/api/tag.ts index b8e87ba..e6f6f06 100644 --- a/client/src/api/tag.ts +++ b/client/src/api/tag.ts @@ -17,9 +17,13 @@ export interface TagStat { total_amount: number } +export interface TagListResult { + list: Tag[] +} + /** 获取标签列表 */ export function getTags() { - return request({ url: '/tags' }) + return request({ url: '/tags' }) } /** 创建标签 */ diff --git a/client/src/pages/add/index.vue b/client/src/pages/add/index.vue index f54e6a3..f5c8d94 100644 --- a/client/src/pages/add/index.vue +++ b/client/src/pages/add/index.vue @@ -33,6 +33,8 @@ size="large" :auto-focus="!viewId" :disabled="!!viewId" + @focus="amountFocused = true" + @blur="amountFocused = false" @confirm="save" /> @@ -65,10 +67,13 @@ - + - 选择标签(选填) + + 选择标签(选填) + 用于跨分类标记账单,便于筛选和统计 + {{ getTagById(tId)?.name || '' }} @@ -79,7 +84,11 @@ - + + + {{ saving ? '保存中...' : (editId ? '保存修改' : '保存') }} + + 选择标签 + 标签用于跨分类标记账单,例如“报销 / 家庭 / 旅行”,便于筛选和统计。 最多选择 5 个标签 (null) const showSuccess = ref(false) const scrollToCat = ref('') const todayStr = localToday +const amountFocused = ref(false) const amountEditorRef = ref | null>(null) // Remember last selected category per type const lastCatByType: Record = { expense: null, income: null } @@ -432,6 +443,31 @@ function goBack() { padding-bottom: calc(#{$numpad-h} + env(safe-area-inset-bottom)); } +.page-save-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 20; + padding: $space-md $space-xl calc($space-md + env(safe-area-inset-bottom)); + background: linear-gradient(180deg, rgba(255, 250, 247, 0), $bg 28%); +} + +.page-save-btn { + @include btn-primary; + width: 100%; + + &.disabled { + opacity: 0.7; + } +} + +.page-save-text { + color: $surface; + font-size: $font-lg; + font-weight: 600; +} + .header-fixed { @include sticky-header; } @@ -594,6 +630,19 @@ function goBack() { min-width: 0; } +.tag-placeholder { + flex: 1; + display: flex; + flex-direction: column; + gap: 4rpx; + min-width: 0; +} + +.tag-help { + font-size: $font-xs; + color: $text-muted; +} + .tag-chips { display: flex; flex-wrap: wrap; @@ -626,6 +675,13 @@ function goBack() { padding: $space-lg $space-xl $space-xs; } +.tag-modal-desc { + font-size: $font-sm; + color: $text-sec; + line-height: 1.5; + padding: 0 $space-xl $space-xs; +} + .tag-modal-hint { font-size: $font-sm; color: $text-muted; diff --git a/client/src/pages/admin/users.vue b/client/src/pages/admin/users.vue index 02b9253..af2e729 100644 --- a/client/src/pages/admin/users.vue +++ b/client/src/pages/admin/users.vue @@ -148,7 +148,7 @@ function handleDelete(user: AdminUser) { success: async (res) => { if (res.confirm) { try { - await deleteUser(user.id) + await deleteUser(user.id, { confirmName: user.nickname }) list.value = list.value.filter(u => u.id !== user.id) uni.showToast({ title: '已删除', icon: 'success' }) } catch (e: any) { diff --git a/client/src/pages/bills/index.vue b/client/src/pages/bills/index.vue index ea16442..eeee6de 100644 --- a/client/src/pages/bills/index.vue +++ b/client/src/pages/bills/index.vue @@ -149,6 +149,7 @@ let loadSeq = 0 // 请求序号,防止并发覆盖 // Snackbar 删除撤销相关 const snackbarVisible = ref(false) const snackbarMsg = ref('') +let snackbarTimer: number | null = null // 左滑提示:只对首个可删除项显示一次,用户看过后不再重复 const swipeHintDismissed = ref(false) @@ -293,27 +294,40 @@ async function onDelete(item: any) { return } // 乐观删除:先从列表移除,显示 Snackbar 供撤销 - txStore.startDelete(item.id) + const itemIndex = txList.value.findIndex(t => t.id === item.id) + const started = txStore.startDelete(item.id, item, itemIndex) + if (!started) { + uni.showToast({ title: '删除失败,请刷新后重试', icon: 'none' }) + return + } txList.value = txList.value.filter(t => t.id !== item.id) - total.value-- + total.value = Math.max(0, total.value - 1) // 更新筛选金额 if (item.type === 'expense') { filteredExpense.value = Math.max(0, filteredExpense.value - item.amount) } else { filteredIncome.value = Math.max(0, filteredIncome.value - item.amount) } - // 显示 Snackbar + // 显示 Snackbar;清理旧定时器,避免连续删除时旧定时器误隐藏新提示 snackbarMsg.value = '已删除 1 条记录' snackbarVisible.value = true - setTimeout(() => { + if (snackbarTimer) clearTimeout(snackbarTimer) + snackbarTimer = setTimeout(async () => { snackbarVisible.value = false - }, 3000) + snackbarTimer = null + const ok = await txStore.confirmDelete() + if (!ok) loadTx(true, true) + }, 3000) as unknown as number } /** 撤销删除 */ function onUndoDelete() { txStore.cancelDelete() snackbarVisible.value = false + if (snackbarTimer) { + clearTimeout(snackbarTimer) + snackbarTimer = null + } // 重新加载以恢复完整列表 loadTx(true) } diff --git a/client/src/stores/tag.ts b/client/src/stores/tag.ts index 6a3e289..52451d7 100644 --- a/client/src/stores/tag.ts +++ b/client/src/stores/tag.ts @@ -17,7 +17,7 @@ export const useTagStore = defineStore('tag', () => { loading.value = true try { const data = await api.getTags() - tags.value = Array.isArray(data) ? data : [] + tags.value = Array.isArray(data) ? data : data.list || [] lastFetchTime.value = Date.now() } catch { tags.value = [] diff --git a/client/src/stores/transaction.ts b/client/src/stores/transaction.ts index afb521a..3fd3a03 100644 --- a/client/src/stores/transaction.ts +++ b/client/src/stores/transaction.ts @@ -69,23 +69,26 @@ export const useTransactionStore = defineStore('transaction', () => { } /** 开始删除(乐观:先从列表移除,3秒内可撤销) */ - function startDelete(id: number) { + function startDelete(id: number, sourceItem?: api.Transaction, sourceIndex?: number) { // 如果有上一条待确认删除,立即确认(不能同时有两条) if (pendingDelete.value) { confirmDelete() } const idx = transactions.value.findIndex(t => t.id === id) - if (idx === -1) return + const item = idx !== -1 ? transactions.value[idx] : sourceItem + if (!item) return false - const item = transactions.value[idx] - transactions.value.splice(idx, 1) + if (idx !== -1) { + transactions.value.splice(idx, 1) + } const timer = setTimeout(() => { confirmDelete() }, 3000) as unknown as number - pendingDelete.value = { id, item, index: idx, timer } + pendingDelete.value = { id, item, index: idx !== -1 ? idx : sourceIndex ?? 0, timer } + return true } /** 撤销删除(恢复到原位置) */ @@ -98,9 +101,9 @@ export const useTransactionStore = defineStore('transaction', () => { pendingDelete.value = null } - /** 确认删除(实际调接口) */ - async function confirmDelete() { - if (!pendingDelete.value) return + /** 确认删除(实际调接口),返回是否删除成功 */ + async function confirmDelete(): Promise { + if (!pendingDelete.value) return true const { id, index, timer } = pendingDelete.value const item = pendingDelete.value.item clearTimeout(timer) @@ -108,10 +111,12 @@ export const useTransactionStore = defineStore('transaction', () => { try { await api.deleteTransaction(id) + return true } catch { // 接口失败,恢复 transactions.value.splice(index, 0, item) uni.showToast({ title: '删除失败', icon: 'none' }) + return false } }