fix(t04): improve add page tags admin and undo delete

This commit is contained in:
2026-06-12 10:17:47 +08:00
parent b33ebf0879
commit 094d564723
7 changed files with 104 additions and 21 deletions

View File

@@ -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 导入

View File

@@ -17,9 +17,13 @@ export interface TagStat {
total_amount: number
}
export interface TagListResult {
list: Tag[]
}
/** 获取标签列表 */
export function getTags() {
return request<Tag[]>({ url: '/tags' })
return request<Tag[] | TagListResult>({ url: '/tags' })
}
/** 创建标签 */

View File

@@ -33,6 +33,8 @@
size="large"
:auto-focus="!viewId"
:disabled="!!viewId"
@focus="amountFocused = true"
@blur="amountFocused = false"
@confirm="save"
/>
@@ -65,10 +67,13 @@
<Icon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" :disabled="!!viewId" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="!viewId && blurAmountEditor()" />
</view>
<view class="extra-row" @tap="!viewId && openTagPicker">
<view class="extra-row" @tap="!viewId && openTagPicker()">
<Icon name="tag" :size="28" color="#8B7E7E" />
<view class="tag-display">
<text class="extra-text" v-if="selectedTagIds.length === 0">选择标签选填</text>
<view v-if="selectedTagIds.length === 0" class="tag-placeholder">
<text class="extra-text">选择标签选填</text>
<text class="tag-help">用于跨分类标记账单便于筛选和统计</text>
</view>
<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>
@@ -79,7 +84,11 @@
</view>
</view>
<view v-if="!viewId && !showSuccess && !amountFocused" class="page-save-bar">
<view class="page-save-btn" :class="{ disabled: saving }" @tap="save">
<text class="page-save-text">{{ saving ? '保存中...' : (editId ? '保存修改' : '保存') }}</text>
</view>
</view>
<SaveSuccess
v-if="!viewId"
@@ -94,6 +103,7 @@
<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-desc">标签用于跨分类标记账单例如报销 / 家庭 / 旅行便于筛选和统计</text>
<text class="tag-modal-hint">最多选择 5 个标签</text>
<scroll-view class="tag-modal-list" scroll-y>
<view
@@ -155,6 +165,7 @@ const viewId = ref<number | null>(null)
const showSuccess = ref(false)
const scrollToCat = ref('')
const todayStr = localToday
const amountFocused = ref(false)
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
// Remember last selected category per type
const lastCatByType: Record<string, number | null> = { 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;

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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 = []

View File

@@ -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]
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<boolean> {
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
}
}