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 } }) return request({ url: `/admin/users/${id}/status`, method: 'PUT', data: { role } })
} }
export interface DeleteUserOptions {
confirmName: string
}
/** 删除用户 */ /** 删除用户 */
export function deleteUser(id: number) { export function deleteUser(id: number, options: DeleteUserOptions) {
return request({ url: `/admin/users/${id}`, method: 'DELETE' }) return request({ url: `/admin/users/${id}`, method: 'DELETE', data: options })
} }
// 健康检查类型从 @/api/health 导入 // 健康检查类型从 @/api/health 导入

View File

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

View File

@@ -1,5 +1,5 @@
<template> <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 <view
class="tx-item" class="tx-item"
:style="{ transform: `translateX(${offsetX}rpx)` }" :style="{ transform: `translateX(${offsetX}rpx)` }"
@@ -18,8 +18,6 @@
<view class="info"> <view class="info">
<view class="name-row"> <view class="name-row">
<text class="name">{{ item.note || item.category_name || '未分类' }}</text> <text class="name">{{ item.note || item.category_name || '未分类' }}</text>
<!-- 群组模式下非本人记录显示小锁图标 -->
<text v-if="isOtherCreator" class="lock-icon">🔒</text>
</view> </view>
<view class="meta"> <view class="meta">
<view v-if="isOtherCreator" class="creator-tag"> <view v-if="isOtherCreator" class="creator-tag">
@@ -202,11 +200,6 @@ function onTouchEnd() {
gap: 8rpx; gap: 8rpx;
} }
.lock-icon {
font-size: 24rpx;
flex-shrink: 0;
}
.name { .name {
font-size: 28rpx; font-size: 28rpx;
font-weight: 500; font-weight: 500;
@@ -214,11 +207,6 @@ function onTouchEnd() {
@include text-ellipsis; @include text-ellipsis;
} }
/* 只读状态:降低整体透明度 */
.read-only .tx-item {
opacity: 0.85;
}
.meta { .meta {
font-size: 24rpx; font-size: 24rpx;
color: $text-sec; color: $text-sec;

View File

@@ -33,6 +33,8 @@
size="large" size="large"
:auto-focus="!viewId" :auto-focus="!viewId"
:disabled="!!viewId" :disabled="!!viewId"
@focus="amountFocused = true"
@blur="amountFocused = false"
@confirm="save" @confirm="save"
/> />
@@ -65,10 +67,13 @@
<Icon name="edit" :size="28" color="#8B7E7E" /> <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()" /> <input class="extra-input" v-model="note" :disabled="!!viewId" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="!viewId && blurAmountEditor()" />
</view> </view>
<view class="extra-row" @tap="!viewId && openTagPicker"> <view class="extra-row" @tap="!viewId && openTagPicker()">
<Icon name="tag" :size="28" color="#8B7E7E" /> <Icon name="tag" :size="28" color="#8B7E7E" />
<view class="tag-display"> <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 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' }"> <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> <text class="tag-chip-text">{{ getTagById(tId)?.name || '' }}</text>
@@ -79,7 +84,11 @@
</view> </view>
</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 <SaveSuccess
v-if="!viewId" v-if="!viewId"
@@ -94,6 +103,7 @@
<view class="modal-mask" v-if="showTagPicker" @tap="showTagPicker = false"> <view class="modal-mask" v-if="showTagPicker" @tap="showTagPicker = false">
<view class="tag-modal" @tap.stop> <view class="tag-modal" @tap.stop>
<text class="tag-modal-title">选择标签</text> <text class="tag-modal-title">选择标签</text>
<text class="tag-modal-desc">标签用于跨分类标记账单例如报销 / 家庭 / 旅行便于筛选和统计</text>
<text class="tag-modal-hint">最多选择 5 个标签</text> <text class="tag-modal-hint">最多选择 5 个标签</text>
<scroll-view class="tag-modal-list" scroll-y> <scroll-view class="tag-modal-list" scroll-y>
<view <view
@@ -155,6 +165,7 @@ const viewId = ref<number | null>(null)
const showSuccess = ref(false) const showSuccess = ref(false)
const scrollToCat = ref('') const scrollToCat = ref('')
const todayStr = localToday const todayStr = localToday
const amountFocused = ref(false)
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null) const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
// Remember last selected category per type // Remember last selected category per type
const lastCatByType: Record<string, number | null> = { expense: null, income: null } 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)); 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 { .header-fixed {
@include sticky-header; @include sticky-header;
} }
@@ -594,6 +630,19 @@ function goBack() {
min-width: 0; 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 { .tag-chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -626,6 +675,13 @@ function goBack() {
padding: $space-lg $space-xl $space-xs; 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 { .tag-modal-hint {
font-size: $font-sm; font-size: $font-sm;
color: $text-muted; color: $text-muted;

View File

@@ -148,7 +148,7 @@ function handleDelete(user: AdminUser) {
success: async (res) => { success: async (res) => {
if (res.confirm) { if (res.confirm) {
try { try {
await deleteUser(user.id) await deleteUser(user.id, { confirmName: user.nickname })
list.value = list.value.filter(u => u.id !== user.id) list.value = list.value.filter(u => u.id !== user.id)
uni.showToast({ title: '已删除', icon: 'success' }) uni.showToast({ title: '已删除', icon: 'success' })
} catch (e: any) { } catch (e: any) {

View File

@@ -149,6 +149,7 @@ let loadSeq = 0 // 请求序号,防止并发覆盖
// Snackbar 删除撤销相关 // Snackbar 删除撤销相关
const snackbarVisible = ref(false) const snackbarVisible = ref(false)
const snackbarMsg = ref('') const snackbarMsg = ref('')
let snackbarTimer: number | null = null
// 左滑提示:只对首个可删除项显示一次,用户看过后不再重复 // 左滑提示:只对首个可删除项显示一次,用户看过后不再重复
const swipeHintDismissed = ref(false) const swipeHintDismissed = ref(false)
@@ -280,8 +281,7 @@ function onFilterApply(filters: FilterParams) {
function onEdit(item: any) { function onEdit(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) { if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
// 非本人记录:进入查看模式(只读) uni.showToast({ title: '不能编辑他人的记录', icon: 'none' })
uni.navigateTo({ url: `/pages/add/index?viewId=${item.id}` })
return return
} }
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` }) uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
@@ -293,27 +293,40 @@ async function onDelete(item: any) {
return return
} }
// 乐观删除:先从列表移除,显示 Snackbar 供撤销 // 乐观删除:先从列表移除,显示 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) txList.value = txList.value.filter(t => t.id !== item.id)
total.value-- total.value = Math.max(0, total.value - 1)
// 更新筛选金额 // 更新筛选金额
if (item.type === 'expense') { if (item.type === 'expense') {
filteredExpense.value = Math.max(0, filteredExpense.value - item.amount) filteredExpense.value = Math.max(0, filteredExpense.value - item.amount)
} else { } else {
filteredIncome.value = Math.max(0, filteredIncome.value - item.amount) filteredIncome.value = Math.max(0, filteredIncome.value - item.amount)
} }
// 显示 Snackbar // 显示 Snackbar;清理旧定时器,避免连续删除时旧定时器误隐藏新提示
snackbarMsg.value = '已删除 1 条记录' snackbarMsg.value = '已删除 1 条记录'
snackbarVisible.value = true snackbarVisible.value = true
setTimeout(() => { if (snackbarTimer) clearTimeout(snackbarTimer)
snackbarTimer = setTimeout(async () => {
snackbarVisible.value = false snackbarVisible.value = false
}, 3000) snackbarTimer = null
const ok = await txStore.confirmDelete()
if (!ok) loadTx(true, true)
}, 3000) as unknown as number
} }
/** 撤销删除 */ /** 撤销删除 */
function onUndoDelete() { function onUndoDelete() {
txStore.cancelDelete() txStore.cancelDelete()
snackbarVisible.value = false snackbarVisible.value = false
if (snackbarTimer) {
clearTimeout(snackbarTimer)
snackbarTimer = null
}
// 重新加载以恢复完整列表 // 重新加载以恢复完整列表
loadTx(true) loadTx(true)
} }

View File

@@ -298,8 +298,7 @@ function goNotifications() {
function onTxTap(item: any) { function onTxTap(item: any) {
if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) { if (groupStore.isGroupMode && item.user_id !== userStore.userInfo?.id) {
// 非本人记录:进入查看模式(只读) uni.showToast({ title: '不能编辑他人的记录', icon: 'none' })
uni.navigateTo({ url: `/pages/add/index?viewId=${item.id}` })
return return
} }
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` }) uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })

View File

@@ -17,7 +17,7 @@ export const useTagStore = defineStore('tag', () => {
loading.value = true loading.value = true
try { try {
const data = await api.getTags() const data = await api.getTags()
tags.value = Array.isArray(data) ? data : [] tags.value = Array.isArray(data) ? data : data.list || []
lastFetchTime.value = Date.now() lastFetchTime.value = Date.now()
} catch { } catch {
tags.value = [] tags.value = []

View File

@@ -69,23 +69,26 @@ export const useTransactionStore = defineStore('transaction', () => {
} }
/** 开始删除乐观先从列表移除3秒内可撤销 */ /** 开始删除乐观先从列表移除3秒内可撤销 */
function startDelete(id: number) { function startDelete(id: number, sourceItem?: api.Transaction, sourceIndex?: number) {
// 如果有上一条待确认删除,立即确认(不能同时有两条) // 如果有上一条待确认删除,立即确认(不能同时有两条)
if (pendingDelete.value) { if (pendingDelete.value) {
confirmDelete() confirmDelete()
} }
const idx = transactions.value.findIndex(t => t.id === id) 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) transactions.value.splice(idx, 1)
}
const timer = setTimeout(() => { const timer = setTimeout(() => {
confirmDelete() confirmDelete()
}, 3000) as unknown as number }, 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 pendingDelete.value = null
} }
/** 确认删除(实际调接口) */ /** 确认删除(实际调接口),返回是否删除成功 */
async function confirmDelete() { async function confirmDelete(): Promise<boolean> {
if (!pendingDelete.value) return if (!pendingDelete.value) return true
const { id, index, timer } = pendingDelete.value const { id, index, timer } = pendingDelete.value
const item = pendingDelete.value.item const item = pendingDelete.value.item
clearTimeout(timer) clearTimeout(timer)
@@ -108,10 +111,12 @@ export const useTransactionStore = defineStore('transaction', () => {
try { try {
await api.deleteTransaction(id) await api.deleteTransaction(id)
return true
} catch { } catch {
// 接口失败,恢复 // 接口失败,恢复
transactions.value.splice(index, 0, item) transactions.value.splice(index, 0, item)
uni.showToast({ title: '删除失败', icon: 'none' }) 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) => { router.get('/track/stats', requireAdmin, async (_req, res) => {
try { try {
// 今日统计 // 今日统计
const [todayRows] = await pool.execute( const [todayRows] = await pool.query(
`SELECT event, COUNT(*) as count FROM track_events `SELECT event, COUNT(*) as count FROM track_events
WHERE DATE(created_at) = CURDATE() WHERE DATE(created_at) = CURDATE()
GROUP BY event ORDER BY count DESC` GROUP BY event ORDER BY count DESC`
) )
// 最近7天每日统计 // 最近7天每日统计
const [dailyRows] = await pool.execute( const [dailyRows] = await pool.query(
`SELECT DATE(created_at) as date, COUNT(*) as count FROM track_events `SELECT DATE(created_at) as date, COUNT(*) as count FROM track_events
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY DATE(created_at) ORDER BY date DESC` GROUP BY DATE(created_at) ORDER BY date DESC`
) )
// 最近7天事件分布 // 最近7天事件分布
const [eventRows] = await pool.execute( const [eventRows] = await pool.query(
`SELECT event, COUNT(*) as count FROM track_events `SELECT event, COUNT(*) as count FROM track_events
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY event ORDER BY count DESC LIMIT 10` GROUP BY event ORDER BY count DESC LIMIT 10`
@@ -151,13 +151,13 @@ router.get('/track/list', requireAdmin, async (req, res) => {
params.push(event) params.push(event)
} }
const [countResult] = await pool.execute( const [countResult] = await pool.query(
`SELECT COUNT(*) as total FROM track_events t WHERE ${where}`, `SELECT COUNT(*) as total FROM track_events t WHERE ${where}`,
params params
) )
const total = (countResult as any[])[0].total const total = (countResult as any[])[0].total
const [rows] = await pool.execute( const [rows] = await pool.query(
`SELECT t.*, u.nickname `SELECT t.*, u.nickname
FROM track_events t FROM track_events t
LEFT JOIN users u ON t.user_id = u.id AND u.deleted_at IS NULL LEFT JOIN users u ON t.user_id = u.id AND u.deleted_at IS NULL