Compare commits

...

1 Commits

Author SHA1 Message Date
218c23687d fix(t04): improve add page tags admin and undo delete 2026-06-15 16:30:29 +08:00
10 changed files with 112 additions and 43 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

@@ -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;

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

View File

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

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]
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
}
}

View File

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