feat: 规则/分类/标签管理批量操作模式与邮箱检查进度时间戳

规则、分类、标签三个管理页统一为「批量操作模式」:默认不显示勾选框,点右上角「批量操作」进入后再勾选与批量操作,退出即清空选择;同时补齐标签、分类的批量删除能力,并把规则「应用规则到匹配账单」的预览弹窗加宽以容纳全部列。此外为邮箱账单「立即检查」的实时进度加上行首时间戳,便于判断进度先后与是否停滞。

变更内容:
- 规则管理:默认隐藏勾选列,进入批量模式后方可勾选;「应用匹配账单」「批量删除」移入批量模式,「AI 生成规则」在批量模式下隐藏。
- 标签管理:新增批量操作模式与批量删除,同时清理交易-标签、规则-标签关联(DELETE /api/tag/batch)。
- 分类管理:新增批量操作模式与批量删除,收入/支出两表勾选合并处理(DELETE /api/category/batch)。
- 应用预览弹窗(RuleApplyPreviewModal)宽度由 min(1200px,94vw) 改为 min(1400px,96vw),表格 max-height 由 420 改为 560。
- 邮箱账单「立即检查」:SSE 推送的进度行与提示、前端追加的结果/失败提示行,统一加 [HH:mm:ss] 行首时间戳。
- 新增 tag/category 批量文案(zh/en/ja)与前端 API 封装 batchDeleteTags / batchDeleteCategories。

说明:
- 标签删除会从所有交易记录中移除该标签,分类删除仅移除分类本身(账单显示为未分类),与原有单条删除行为一致。
- 后端批量删除均按 userId 过滤归属,仅删除属于当前用户的记录,返回实际删除数。
- 邮箱时间戳为纯展示改动,不影响业务逻辑与数据。
This commit is contained in:
2026-10-14 01:21:38 +08:00
parent 524fa52908
commit 39b0c36d08
15 changed files with 326 additions and 19 deletions
+5
View File
@@ -25,3 +25,8 @@ export function updateCategory(id: number, data: { name: string; type: string; i
export function deleteCategory(id: number) {
return request.delete(`/category/${id}`)
}
/** 批量删除分类,返回实际删除数量 */
export function batchDeleteCategories(ids: number[]) {
return request.delete('/category/batch', { data: { ids } })
}
+5
View File
@@ -25,3 +25,8 @@ export function updateTag(id: number, data: { name: string; color?: string; sort
export function deleteTag(id: number) {
return request.delete(`/tag/${id}`)
}
/** 批量删除标签,返回实际删除数量 */
export function batchDeleteTags(ids: number[]) {
return request.delete('/tag/batch', { data: { ids } })
}
@@ -128,7 +128,7 @@ async function handleApply() {
</script>
<template>
<NModal v-model:show="showModel" preset="card" :title="t('rule.applyTitle')" style="width: min(1200px, 94vw)">
<NModal v-model:show="showModel" preset="card" :title="t('rule.applyTitle')" style="width: min(1400px, 96vw)">
<NSpace vertical :size="12">
<template v-if="preview">
<NAlert v-if="matchedCount === 0" type="warning" :show-icon="true">{{ t('rule.applyNoMatch') }}</NAlert>
@@ -150,7 +150,7 @@ async function handleApply() {
:columns="columns"
:data="preview?.transactions || []"
:loading="loading"
:max-height="420"
:max-height="560"
:scroll-x="1250"
:bordered="false"
size="small"
+38
View File
@@ -1030,6 +1030,24 @@ export default {
en: 'Are you sure you want to delete this category?',
ja: 'このカテゴリを削除してもよろしいですか?',
},
batchMode: { zh: '批量操作', en: 'Batch', ja: '一括操作' },
exitBatchMode: { zh: '退出批量', en: 'Exit Batch', ja: '一括終了' },
batchDelete: { zh: '批量删除', en: 'Batch Delete', ja: '一括削除' },
batchDeleteNone: {
zh: '请先选择要删除的分类',
en: 'Please select categories to delete',
ja: '削除するカテゴリを選択してください',
},
batchDeleteConfirm: {
zh: '确定删除选中的 {count} 个分类吗?',
en: 'Delete the {count} selected categor(y/ies)?',
ja: '選択した {count} 件のカテゴリを削除しますか?',
},
batchDeleted: {
zh: '已删除 {count} 个分类',
en: 'Deleted {count} categor(y/ies)',
ja: '{count} 件のカテゴリを削除しました',
},
},
// 标签管理
@@ -1049,6 +1067,24 @@ export default {
en: 'This tag will be removed from all transactions. Delete it?',
ja: '削除するとすべての取引記録からこのタグが外れます。削除してもよろしいですか?',
},
batchMode: { zh: '批量操作', en: 'Batch', ja: '一括操作' },
exitBatchMode: { zh: '退出批量', en: 'Exit Batch', ja: '一括終了' },
batchDelete: { zh: '批量删除', en: 'Batch Delete', ja: '一括削除' },
batchDeleteNone: {
zh: '请先选择要删除的标签',
en: 'Please select tags to delete',
ja: '削除するタグを選択してください',
},
batchDeleteConfirm: {
zh: '确定删除选中的 {count} 个标签吗?删除后将从所有交易记录中移除。',
en: 'Delete the {count} selected tag(s)? They will be removed from all transactions.',
ja: '選択した {count} 件のタグを削除しますか?すべての取引記録から外れます。',
},
batchDeleted: {
zh: '已删除 {count} 个标签',
en: 'Deleted {count} tag(s)',
ja: '{count} 件のタグを削除しました',
},
},
// 交易渠道管理(用户自定义)
@@ -1123,6 +1159,8 @@ export default {
status: { zh: '启用状态', en: 'Status', ja: '有効状態' },
statusOn: { zh: '已启用', en: 'Enabled', ja: '有効' },
statusOff: { zh: '已停用', en: 'Disabled', ja: '無効' },
batchMode: { zh: '批量操作', en: 'Batch', ja: '一括操作' },
exitBatchMode: { zh: '退出批量', en: 'Exit Batch', ja: '一括終了' },
batchDelete: { zh: '批量删除', en: 'Batch Delete', ja: '一括削除' },
batchDeleteNone: {
zh: '请先选择要删除的规则',
+65 -2
View File
@@ -16,7 +16,7 @@ import {
NSelect,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getCategoryPage, createCategory, updateCategory, deleteCategory } from '../api/category'
import { getCategoryPage, createCategory, updateCategory, deleteCategory, batchDeleteCategories } from '../api/category'
import { getAiConfig, suggestEmoji, parseCategoryByAi } from '../api/ai'
import EmojiPicker from '../components/EmojiPicker.vue'
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
@@ -43,6 +43,9 @@ interface CategoryItem {
const expenseList = ref<CategoryItem[]>([]) // 支出分类列表(当前页)
const incomeList = ref<CategoryItem[]>([]) // 收入分类列表(当前页)
const loading = ref(false) // 列表加载状态
const selectionMode = ref(false) // 批量选择模式:开启后才显示多选列
const checkedExpenseIds = ref<number[]>([]) // 支出分类表格多选选中的ID(用于批量删除)
const checkedIncomeIds = ref<number[]>([]) // 收入分类表格多选选中的ID(用于批量删除)
// 支出分类分页
const expensePage = ref(1)
@@ -88,6 +91,8 @@ const typeOptions = computed(() => [
/** 创建表格列配置 */
function createColumns(): DataTableColumns<CategoryItem> {
return [
// 多选列:仅在批量选择模式下显示,默认不渲染复选框
...(selectionMode.value ? [{ type: 'selection' as const }] : []),
{
title: t('common.name'),
key: 'name',
@@ -254,6 +259,47 @@ function handleDelete(id: number) {
})
}
/** 进入批量选择模式(显示多选列) */
function enterSelectionMode() {
selectionMode.value = true
checkedExpenseIds.value = []
checkedIncomeIds.value = []
}
/** 退出批量选择模式并清空已选 */
function exitSelectionMode() {
selectionMode.value = false
checkedExpenseIds.value = []
checkedIncomeIds.value = []
}
/** 批量删除选中的分类:合并收入/支出两表勾选,确认后调用接口并刷新列表 */
function handleBatchDelete() {
const ids = [...checkedExpenseIds.value, ...checkedIncomeIds.value]
const count = ids.length
if (count === 0) {
message.warning(t('category.batchDeleteNone'))
return
}
dialog.warning({
title: t('common.confirmDelete'),
content: t('category.batchDeleteConfirm', { count }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
const res: any = await batchDeleteCategories(ids)
message.success(t('category.batchDeleted', { count: res.data }))
checkedExpenseIds.value = []
checkedIncomeIds.value = []
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.deleteFailed'))
}
},
})
}
/** 加载 AI 启用状态,用于控制推荐入口显示 */
async function loadAiConfig() {
try {
@@ -319,18 +365,33 @@ onMounted(() => {
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px">
<h2 style="margin: 0">{{ t('category.title') }}</h2>
<NButton type="primary" @click="handleAdd">{{ t('category.add') }}</NButton>
<NSpace>
<NButton v-if="!selectionMode" @click="enterSelectionMode">{{ t('category.batchMode') }}</NButton>
<template v-else>
<NButton @click="exitSelectionMode">{{ t('category.exitBatchMode') }}</NButton>
<NButton
type="error"
:disabled="checkedExpenseIds.length === 0 && checkedIncomeIds.length === 0"
@click="handleBatchDelete"
>
{{ t('category.batchDelete') }}
</NButton>
</template>
<NButton type="primary" @click="handleAdd">{{ t('category.add') }}</NButton>
</NSpace>
</NSpace>
<!-- 支出分类列表 -->
<h3>{{ t('category.expenseSection') }}</h3>
<NDataTable
v-model:checked-row-keys="checkedExpenseIds"
remote
:columns="columns"
:data="expenseList"
:loading="loading"
:bordered="false"
:scroll-x="520"
:row-key="(row: CategoryItem) => row.id"
:pagination="expensePagination"
@update:page="handleExpensePageChange"
@update:page-size="handleExpensePageSizeChange"
@@ -339,12 +400,14 @@ onMounted(() => {
<!-- 收入分类列表 -->
<h3 style="margin-top: 24px">{{ t('category.incomeSection') }}</h3>
<NDataTable
v-model:checked-row-keys="checkedIncomeIds"
remote
:columns="columns"
:data="incomeList"
:loading="loading"
:bordered="false"
:scroll-x="520"
:row-key="(row: CategoryItem) => row.id"
:pagination="incomePagination"
@update:page="handleIncomePageChange"
@update:page-size="handleIncomePageSizeChange"
+10 -2
View File
@@ -416,6 +416,13 @@ async function handleTestConfig(row: EmailBillConfig) {
}
}
/** 生成检查进度行首的时间戳前缀,形如 [21:35:26] */
function checkTimestamp(): string {
const d = new Date()
const p = (n: number) => String(n).padStart(2, '0')
return `[${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}] `
}
/** 立即检查:建立 SSE 连接并实时展示执行进度 */
function handleCheckConfig(row: EmailBillConfig) {
stopCheckStream()
@@ -434,7 +441,7 @@ function handleCheckConfig(row: EmailBillConfig) {
} catch {
count = 0
}
checkLogs.value.push(t('emailBill.config.checkResult', { count }))
checkLogs.value.push(checkTimestamp() + t('emailBill.config.checkResult', { count }))
finishCheck()
await Promise.all([loadConfigs(), loadMessages()])
} else if (event === 'failed') {
@@ -445,7 +452,8 @@ function handleCheckConfig(row: EmailBillConfig) {
reason = ''
}
checkLogs.value.push(
reason ? `${t('emailBill.config.checkFailed')}:${reason}` : t('emailBill.config.checkFailed'),
checkTimestamp() +
(reason ? `${t('emailBill.config.checkFailed')}:${reason}` : t('emailBill.config.checkFailed')),
)
finishCheck()
loadConfigs()
+26 -9
View File
@@ -73,6 +73,7 @@ const searchKeyword = ref('')
const filterEnabled = ref<string | null>(null) // 'ON' | 'OFF' | null
const filterType = ref<string | null>(null)
const checkedRuleIds = ref<number[]>([]) // 表格多选选中的规则ID(用于批量删除/批量应用)
const selectionMode = ref(false) // 批量选择模式:开启后才显示多选列
// 规则应用预览弹窗状态
const showApplyModal = ref(false)
@@ -196,8 +197,8 @@ function conditionTags(row: RuleItem) {
/** 创建表格列配置 */
const columns = computed<DataTableColumns<RuleItem>>(() => [
// 多选列:用于批量操作(如批量删除)
{ type: 'selection' },
// 多选列:仅在批量选择模式下显示,默认不渲染复选框
...(selectionMode.value ? [{ type: 'selection' as const }] : []),
{
title: t('common.name'),
key: 'name',
@@ -736,6 +737,18 @@ function handleBatchDelete() {
})
}
/** 进入批量选择模式(显示多选列) */
function enterSelectionMode() {
selectionMode.value = true
checkedRuleIds.value = []
}
/** 退出批量选择模式并清空已选 */
function exitSelectionMode() {
selectionMode.value = false
checkedRuleIds.value = []
}
/** 打开单条规则的应用预览弹窗 */
function handleApplyRule(row: RuleItem) {
applyRuleIds.value = [row.id]
@@ -785,13 +798,17 @@ onMounted(() => {
<NSpace justify="space-between" style="margin-bottom: 16px">
<h2 style="margin: 0">{{ t('rule.title') }}</h2>
<NSpace>
<NButton v-if="aiEnabled" @click="handleAiRuleOpen">{{ t('ai.rule.entry') }}</NButton>
<NButton :disabled="checkedRuleIds.length === 0" @click="handleApplySelected">
{{ t('rule.batchApply') }}
</NButton>
<NButton type="error" :disabled="checkedRuleIds.length === 0" @click="handleBatchDelete">
{{ t('rule.batchDelete') }}
</NButton>
<NButton v-if="aiEnabled && !selectionMode" @click="handleAiRuleOpen">{{ t('ai.rule.entry') }}</NButton>
<NButton v-if="!selectionMode" @click="enterSelectionMode">{{ t('rule.batchMode') }}</NButton>
<template v-else>
<NButton @click="exitSelectionMode">{{ t('rule.exitBatchMode') }}</NButton>
<NButton :disabled="checkedRuleIds.length === 0" @click="handleApplySelected">
{{ t('rule.batchApply') }}
</NButton>
<NButton type="error" :disabled="checkedRuleIds.length === 0" @click="handleBatchDelete">
{{ t('rule.batchDelete') }}
</NButton>
</template>
<NButton type="primary" @click="handleAdd">{{ t('rule.add') }}</NButton>
</NSpace>
</NSpace>
+54 -2
View File
@@ -17,7 +17,7 @@ import {
NTag,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getTagPage, createTag, updateTag, deleteTag } from '../api/tag'
import { getTagPage, createTag, updateTag, deleteTag, batchDeleteTags } from '../api/tag'
import { getAiConfig, suggestColor } from '../api/ai'
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
import { useTablePageSize } from '../composables/useTablePageSize'
@@ -41,6 +41,8 @@ interface TagItem {
// 列表和弹窗状态
const tags = ref<TagItem[]>([]) // 标签列表(当前页)
const loading = ref(false) // 列表加载状态
const checkedTagIds = ref<number[]>([]) // 表格多选选中的标签ID(用于批量删除)
const selectionMode = ref(false) // 批量选择模式:开启后才显示多选列
// 标签列表分页
const page = ref(1)
@@ -81,6 +83,8 @@ const colorSwatches = [
/** 创建表格列配置 */
function createColumns(): DataTableColumns<TagItem> {
return [
// 多选列:仅在批量选择模式下显示,默认不渲染复选框
...(selectionMode.value ? [{ type: 'selection' as const }] : []),
{
title: t('tag.label'),
key: 'name',
@@ -217,6 +221,43 @@ function handleDelete(id: number) {
})
}
/** 进入批量选择模式(显示多选列) */
function enterSelectionMode() {
selectionMode.value = true
checkedTagIds.value = []
}
/** 退出批量选择模式并清空已选 */
function exitSelectionMode() {
selectionMode.value = false
checkedTagIds.value = []
}
/** 批量删除选中的标签:确认后调用接口并刷新列表 */
function handleBatchDelete() {
const count = checkedTagIds.value.length
if (count === 0) {
message.warning(t('tag.batchDeleteNone'))
return
}
dialog.warning({
title: t('common.confirmDelete'),
content: t('tag.batchDeleteConfirm', { count }),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
const res: any = await batchDeleteTags(checkedTagIds.value)
message.success(t('tag.batchDeleted', { count: res.data }))
checkedTagIds.value = []
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.deleteFailed'))
}
},
})
}
/** 加载 AI 启用状态,用于控制推荐入口显示 */
async function loadAiConfig() {
try {
@@ -257,17 +298,28 @@ onMounted(() => {
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px">
<h2 style="margin: 0">{{ t('tag.title') }}</h2>
<NButton type="primary" @click="handleAdd">{{ t('tag.add') }}</NButton>
<NSpace>
<NButton v-if="!selectionMode" @click="enterSelectionMode">{{ t('tag.batchMode') }}</NButton>
<template v-else>
<NButton @click="exitSelectionMode">{{ t('tag.exitBatchMode') }}</NButton>
<NButton type="error" :disabled="checkedTagIds.length === 0" @click="handleBatchDelete">
{{ t('tag.batchDelete') }}
</NButton>
</template>
<NButton type="primary" @click="handleAdd">{{ t('tag.add') }}</NButton>
</NSpace>
</NSpace>
<!-- 标签数据表格 -->
<NDataTable
v-model:checked-row-keys="checkedTagIds"
remote
:columns="columns"
:data="tags"
:loading="loading"
:bordered="false"
:scroll-x="560"
:row-key="(row: TagItem) => row.id"
:pagination="pagination"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"