fix(t04): improve add page tags admin and undo delete
This commit is contained in:
@@ -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 导入
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
/** 创建标签 */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="tx-item-wrap" :class="{ 'show-hint': swipeHint && !disableSwipe, 'read-only': isOtherCreator }">
|
||||
<view class="tx-item-wrap" :class="{ 'show-hint': swipeHint && !disableSwipe }">
|
||||
<view
|
||||
class="tx-item"
|
||||
:style="{ transform: `translateX(${offsetX}rpx)` }"
|
||||
@@ -18,8 +18,6 @@
|
||||
<view class="info">
|
||||
<view class="name-row">
|
||||
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
|
||||
<!-- 群组模式下非本人记录显示小锁图标 -->
|
||||
<text v-if="isOtherCreator" class="lock-icon">🔒</text>
|
||||
</view>
|
||||
<view class="meta">
|
||||
<view v-if="isOtherCreator" class="creator-tag">
|
||||
@@ -202,11 +200,6 @@ function onTouchEnd() {
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
font-size: 24rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
@@ -214,11 +207,6 @@ function onTouchEnd() {
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
||||
/* 只读状态:降低整体透明度 */
|
||||
.read-only .tx-item {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 24rpx;
|
||||
color: $text-sec;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -149,6 +149,7 @@ let loadSeq = 0 // 请求序号,防止并发覆盖
|
||||
// Snackbar 删除撤销相关
|
||||
const snackbarVisible = ref(false)
|
||||
const snackbarMsg = ref('')
|
||||
let snackbarTimer: number | null = null
|
||||
|
||||
// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复
|
||||
const swipeHintDismissed = ref(false)
|
||||
@@ -280,8 +281,7 @@ function onFilterApply(filters: FilterParams) {
|
||||
|
||||
function onEdit(item: any) {
|
||||
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
|
||||
// 非本人记录:进入查看模式(只读)
|
||||
uni.navigateTo({ url: `/pages/add/index?viewId=${item.id}` })
|
||||
uni.showToast({ title: '不能编辑他人的记录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
|
||||
@@ -293,27 +293,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)
|
||||
}
|
||||
|
||||
@@ -298,8 +298,7 @@ function goNotifications() {
|
||||
|
||||
function onTxTap(item: any) {
|
||||
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
|
||||
// 非本人记录:进入查看模式(只读)
|
||||
uni.navigateTo({ url: `/pages/add/index?viewId=${item.id}` })
|
||||
uni.showToast({ title: '不能编辑他人的记录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,21 +101,21 @@ router.get('/stats', requireAdmin, (_req, res) => {
|
||||
router.get('/track/stats', requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
// 今日统计
|
||||
const [todayRows] = await pool.execute(
|
||||
const [todayRows] = await pool.query(
|
||||
`SELECT event, COUNT(*) as count FROM track_events
|
||||
WHERE DATE(created_at) = CURDATE()
|
||||
GROUP BY event ORDER BY count DESC`
|
||||
)
|
||||
|
||||
// 最近7天每日统计
|
||||
const [dailyRows] = await pool.execute(
|
||||
const [dailyRows] = await pool.query(
|
||||
`SELECT DATE(created_at) as date, COUNT(*) as count FROM track_events
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||
GROUP BY DATE(created_at) ORDER BY date DESC`
|
||||
)
|
||||
|
||||
// 最近7天事件分布
|
||||
const [eventRows] = await pool.execute(
|
||||
const [eventRows] = await pool.query(
|
||||
`SELECT event, COUNT(*) as count FROM track_events
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||
GROUP BY event ORDER BY count DESC LIMIT 10`
|
||||
@@ -151,13 +151,13 @@ router.get('/track/list', requireAdmin, async (req, res) => {
|
||||
params.push(event)
|
||||
}
|
||||
|
||||
const [countResult] = await pool.execute(
|
||||
const [countResult] = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM track_events t WHERE ${where}`,
|
||||
params
|
||||
)
|
||||
const total = (countResult as any[])[0].total
|
||||
|
||||
const [rows] = await pool.execute(
|
||||
const [rows] = await pool.query(
|
||||
`SELECT t.*, u.nickname
|
||||
FROM track_events t
|
||||
LEFT JOIN users u ON t.user_id = u.id AND u.deleted_at IS NULL
|
||||
|
||||
Reference in New Issue
Block a user