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:
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: '请先选择要删除的规则',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user