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) {
|
export function deleteCategory(id: number) {
|
||||||
return request.delete(`/category/${id}`)
|
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) {
|
export function deleteTag(id: number) {
|
||||||
return request.delete(`/tag/${id}`)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<NSpace vertical :size="12">
|
||||||
<template v-if="preview">
|
<template v-if="preview">
|
||||||
<NAlert v-if="matchedCount === 0" type="warning" :show-icon="true">{{ t('rule.applyNoMatch') }}</NAlert>
|
<NAlert v-if="matchedCount === 0" type="warning" :show-icon="true">{{ t('rule.applyNoMatch') }}</NAlert>
|
||||||
@@ -150,7 +150,7 @@ async function handleApply() {
|
|||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="preview?.transactions || []"
|
:data="preview?.transactions || []"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:max-height="420"
|
:max-height="560"
|
||||||
:scroll-x="1250"
|
:scroll-x="1250"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1030,6 +1030,24 @@ export default {
|
|||||||
en: 'Are you sure you want to delete this category?',
|
en: 'Are you sure you want to delete this category?',
|
||||||
ja: 'このカテゴリを削除してもよろしいですか?',
|
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?',
|
en: 'This tag will be removed from all transactions. Delete it?',
|
||||||
ja: '削除するとすべての取引記録からこのタグが外れます。削除してもよろしいですか?',
|
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: '有効状態' },
|
status: { zh: '启用状态', en: 'Status', ja: '有効状態' },
|
||||||
statusOn: { zh: '已启用', en: 'Enabled', ja: '有効' },
|
statusOn: { zh: '已启用', en: 'Enabled', ja: '有効' },
|
||||||
statusOff: { zh: '已停用', en: 'Disabled', ja: '無効' },
|
statusOff: { zh: '已停用', en: 'Disabled', ja: '無効' },
|
||||||
|
batchMode: { zh: '批量操作', en: 'Batch', ja: '一括操作' },
|
||||||
|
exitBatchMode: { zh: '退出批量', en: 'Exit Batch', ja: '一括終了' },
|
||||||
batchDelete: { zh: '批量删除', en: 'Batch Delete', ja: '一括削除' },
|
batchDelete: { zh: '批量删除', en: 'Batch Delete', ja: '一括削除' },
|
||||||
batchDeleteNone: {
|
batchDeleteNone: {
|
||||||
zh: '请先选择要删除的规则',
|
zh: '请先选择要删除的规则',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
NSelect,
|
NSelect,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import type { DataTableColumns } 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 { getAiConfig, suggestEmoji, parseCategoryByAi } from '../api/ai'
|
||||||
import EmojiPicker from '../components/EmojiPicker.vue'
|
import EmojiPicker from '../components/EmojiPicker.vue'
|
||||||
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
|
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
|
||||||
@@ -43,6 +43,9 @@ interface CategoryItem {
|
|||||||
const expenseList = ref<CategoryItem[]>([]) // 支出分类列表(当前页)
|
const expenseList = ref<CategoryItem[]>([]) // 支出分类列表(当前页)
|
||||||
const incomeList = ref<CategoryItem[]>([]) // 收入分类列表(当前页)
|
const incomeList = ref<CategoryItem[]>([]) // 收入分类列表(当前页)
|
||||||
const loading = ref(false) // 列表加载状态
|
const loading = ref(false) // 列表加载状态
|
||||||
|
const selectionMode = ref(false) // 批量选择模式:开启后才显示多选列
|
||||||
|
const checkedExpenseIds = ref<number[]>([]) // 支出分类表格多选选中的ID(用于批量删除)
|
||||||
|
const checkedIncomeIds = ref<number[]>([]) // 收入分类表格多选选中的ID(用于批量删除)
|
||||||
|
|
||||||
// 支出分类分页
|
// 支出分类分页
|
||||||
const expensePage = ref(1)
|
const expensePage = ref(1)
|
||||||
@@ -88,6 +91,8 @@ const typeOptions = computed(() => [
|
|||||||
/** 创建表格列配置 */
|
/** 创建表格列配置 */
|
||||||
function createColumns(): DataTableColumns<CategoryItem> {
|
function createColumns(): DataTableColumns<CategoryItem> {
|
||||||
return [
|
return [
|
||||||
|
// 多选列:仅在批量选择模式下显示,默认不渲染复选框
|
||||||
|
...(selectionMode.value ? [{ type: 'selection' as const }] : []),
|
||||||
{
|
{
|
||||||
title: t('common.name'),
|
title: t('common.name'),
|
||||||
key: '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 启用状态,用于控制推荐入口显示 */
|
/** 加载 AI 启用状态,用于控制推荐入口显示 */
|
||||||
async function loadAiConfig() {
|
async function loadAiConfig() {
|
||||||
try {
|
try {
|
||||||
@@ -319,18 +365,33 @@ onMounted(() => {
|
|||||||
<!-- 顶部:标题和新增按钮 -->
|
<!-- 顶部:标题和新增按钮 -->
|
||||||
<NSpace justify="space-between" style="margin-bottom: 16px">
|
<NSpace justify="space-between" style="margin-bottom: 16px">
|
||||||
<h2 style="margin: 0">{{ t('category.title') }}</h2>
|
<h2 style="margin: 0">{{ t('category.title') }}</h2>
|
||||||
|
<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>
|
<NButton type="primary" @click="handleAdd">{{ t('category.add') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
<!-- 支出分类列表 -->
|
<!-- 支出分类列表 -->
|
||||||
<h3>{{ t('category.expenseSection') }}</h3>
|
<h3>{{ t('category.expenseSection') }}</h3>
|
||||||
<NDataTable
|
<NDataTable
|
||||||
|
v-model:checked-row-keys="checkedExpenseIds"
|
||||||
remote
|
remote
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="expenseList"
|
:data="expenseList"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
:scroll-x="520"
|
:scroll-x="520"
|
||||||
|
:row-key="(row: CategoryItem) => row.id"
|
||||||
:pagination="expensePagination"
|
:pagination="expensePagination"
|
||||||
@update:page="handleExpensePageChange"
|
@update:page="handleExpensePageChange"
|
||||||
@update:page-size="handleExpensePageSizeChange"
|
@update:page-size="handleExpensePageSizeChange"
|
||||||
@@ -339,12 +400,14 @@ onMounted(() => {
|
|||||||
<!-- 收入分类列表 -->
|
<!-- 收入分类列表 -->
|
||||||
<h3 style="margin-top: 24px">{{ t('category.incomeSection') }}</h3>
|
<h3 style="margin-top: 24px">{{ t('category.incomeSection') }}</h3>
|
||||||
<NDataTable
|
<NDataTable
|
||||||
|
v-model:checked-row-keys="checkedIncomeIds"
|
||||||
remote
|
remote
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="incomeList"
|
:data="incomeList"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
:scroll-x="520"
|
:scroll-x="520"
|
||||||
|
:row-key="(row: CategoryItem) => row.id"
|
||||||
:pagination="incomePagination"
|
:pagination="incomePagination"
|
||||||
@update:page="handleIncomePageChange"
|
@update:page="handleIncomePageChange"
|
||||||
@update:page-size="handleIncomePageSizeChange"
|
@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 连接并实时展示执行进度 */
|
/** 立即检查:建立 SSE 连接并实时展示执行进度 */
|
||||||
function handleCheckConfig(row: EmailBillConfig) {
|
function handleCheckConfig(row: EmailBillConfig) {
|
||||||
stopCheckStream()
|
stopCheckStream()
|
||||||
@@ -434,7 +441,7 @@ function handleCheckConfig(row: EmailBillConfig) {
|
|||||||
} catch {
|
} catch {
|
||||||
count = 0
|
count = 0
|
||||||
}
|
}
|
||||||
checkLogs.value.push(t('emailBill.config.checkResult', { count }))
|
checkLogs.value.push(checkTimestamp() + t('emailBill.config.checkResult', { count }))
|
||||||
finishCheck()
|
finishCheck()
|
||||||
await Promise.all([loadConfigs(), loadMessages()])
|
await Promise.all([loadConfigs(), loadMessages()])
|
||||||
} else if (event === 'failed') {
|
} else if (event === 'failed') {
|
||||||
@@ -445,7 +452,8 @@ function handleCheckConfig(row: EmailBillConfig) {
|
|||||||
reason = ''
|
reason = ''
|
||||||
}
|
}
|
||||||
checkLogs.value.push(
|
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()
|
finishCheck()
|
||||||
loadConfigs()
|
loadConfigs()
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const searchKeyword = ref('')
|
|||||||
const filterEnabled = ref<string | null>(null) // 'ON' | 'OFF' | null
|
const filterEnabled = ref<string | null>(null) // 'ON' | 'OFF' | null
|
||||||
const filterType = ref<string | null>(null)
|
const filterType = ref<string | null>(null)
|
||||||
const checkedRuleIds = ref<number[]>([]) // 表格多选选中的规则ID(用于批量删除/批量应用)
|
const checkedRuleIds = ref<number[]>([]) // 表格多选选中的规则ID(用于批量删除/批量应用)
|
||||||
|
const selectionMode = ref(false) // 批量选择模式:开启后才显示多选列
|
||||||
|
|
||||||
// 规则应用预览弹窗状态
|
// 规则应用预览弹窗状态
|
||||||
const showApplyModal = ref(false)
|
const showApplyModal = ref(false)
|
||||||
@@ -196,8 +197,8 @@ function conditionTags(row: RuleItem) {
|
|||||||
|
|
||||||
/** 创建表格列配置 */
|
/** 创建表格列配置 */
|
||||||
const columns = computed<DataTableColumns<RuleItem>>(() => [
|
const columns = computed<DataTableColumns<RuleItem>>(() => [
|
||||||
// 多选列:用于批量操作(如批量删除)
|
// 多选列:仅在批量选择模式下显示,默认不渲染复选框
|
||||||
{ type: 'selection' },
|
...(selectionMode.value ? [{ type: 'selection' as const }] : []),
|
||||||
{
|
{
|
||||||
title: t('common.name'),
|
title: t('common.name'),
|
||||||
key: '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) {
|
function handleApplyRule(row: RuleItem) {
|
||||||
applyRuleIds.value = [row.id]
|
applyRuleIds.value = [row.id]
|
||||||
@@ -785,13 +798,17 @@ onMounted(() => {
|
|||||||
<NSpace justify="space-between" style="margin-bottom: 16px">
|
<NSpace justify="space-between" style="margin-bottom: 16px">
|
||||||
<h2 style="margin: 0">{{ t('rule.title') }}</h2>
|
<h2 style="margin: 0">{{ t('rule.title') }}</h2>
|
||||||
<NSpace>
|
<NSpace>
|
||||||
<NButton v-if="aiEnabled" @click="handleAiRuleOpen">{{ t('ai.rule.entry') }}</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">
|
<NButton :disabled="checkedRuleIds.length === 0" @click="handleApplySelected">
|
||||||
{{ t('rule.batchApply') }}
|
{{ t('rule.batchApply') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton type="error" :disabled="checkedRuleIds.length === 0" @click="handleBatchDelete">
|
<NButton type="error" :disabled="checkedRuleIds.length === 0" @click="handleBatchDelete">
|
||||||
{{ t('rule.batchDelete') }}
|
{{ t('rule.batchDelete') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
|
</template>
|
||||||
<NButton type="primary" @click="handleAdd">{{ t('rule.add') }}</NButton>
|
<NButton type="primary" @click="handleAdd">{{ t('rule.add') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
NTag,
|
NTag,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import type { DataTableColumns } 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 { getAiConfig, suggestColor } from '../api/ai'
|
||||||
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
|
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
|
||||||
import { useTablePageSize } from '../composables/useTablePageSize'
|
import { useTablePageSize } from '../composables/useTablePageSize'
|
||||||
@@ -41,6 +41,8 @@ interface TagItem {
|
|||||||
// 列表和弹窗状态
|
// 列表和弹窗状态
|
||||||
const tags = ref<TagItem[]>([]) // 标签列表(当前页)
|
const tags = ref<TagItem[]>([]) // 标签列表(当前页)
|
||||||
const loading = ref(false) // 列表加载状态
|
const loading = ref(false) // 列表加载状态
|
||||||
|
const checkedTagIds = ref<number[]>([]) // 表格多选选中的标签ID(用于批量删除)
|
||||||
|
const selectionMode = ref(false) // 批量选择模式:开启后才显示多选列
|
||||||
|
|
||||||
// 标签列表分页
|
// 标签列表分页
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
@@ -81,6 +83,8 @@ const colorSwatches = [
|
|||||||
/** 创建表格列配置 */
|
/** 创建表格列配置 */
|
||||||
function createColumns(): DataTableColumns<TagItem> {
|
function createColumns(): DataTableColumns<TagItem> {
|
||||||
return [
|
return [
|
||||||
|
// 多选列:仅在批量选择模式下显示,默认不渲染复选框
|
||||||
|
...(selectionMode.value ? [{ type: 'selection' as const }] : []),
|
||||||
{
|
{
|
||||||
title: t('tag.label'),
|
title: t('tag.label'),
|
||||||
key: 'name',
|
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 启用状态,用于控制推荐入口显示 */
|
/** 加载 AI 启用状态,用于控制推荐入口显示 */
|
||||||
async function loadAiConfig() {
|
async function loadAiConfig() {
|
||||||
try {
|
try {
|
||||||
@@ -257,17 +298,28 @@ onMounted(() => {
|
|||||||
<!-- 顶部:标题和新增按钮 -->
|
<!-- 顶部:标题和新增按钮 -->
|
||||||
<NSpace justify="space-between" style="margin-bottom: 16px">
|
<NSpace justify="space-between" style="margin-bottom: 16px">
|
||||||
<h2 style="margin: 0">{{ t('tag.title') }}</h2>
|
<h2 style="margin: 0">{{ t('tag.title') }}</h2>
|
||||||
|
<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>
|
<NButton type="primary" @click="handleAdd">{{ t('tag.add') }}</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
<!-- 标签数据表格 -->
|
<!-- 标签数据表格 -->
|
||||||
<NDataTable
|
<NDataTable
|
||||||
|
v-model:checked-row-keys="checkedTagIds"
|
||||||
remote
|
remote
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="tags"
|
:data="tags"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
:scroll-x="560"
|
:scroll-x="560"
|
||||||
|
:row-key="(row: TagItem) => row.id"
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
@update:page="handlePageChange"
|
@update:page="handlePageChange"
|
||||||
@update:page-size="handlePageSizeChange"
|
@update:page-size="handlePageSizeChange"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import cn.rosercode.ledgerly.common.PageResult;
|
import cn.rosercode.ledgerly.common.PageResult;
|
||||||
import cn.rosercode.ledgerly.common.Result;
|
import cn.rosercode.ledgerly.common.Result;
|
||||||
|
import cn.rosercode.ledgerly.dto.BatchDeleteRequest;
|
||||||
import cn.rosercode.ledgerly.dto.CategoryRequest;
|
import cn.rosercode.ledgerly.dto.CategoryRequest;
|
||||||
import cn.rosercode.ledgerly.service.CategoryService;
|
import cn.rosercode.ledgerly.service.CategoryService;
|
||||||
import cn.rosercode.ledgerly.util.UserContext;
|
import cn.rosercode.ledgerly.util.UserContext;
|
||||||
@@ -116,4 +117,17 @@ public class CategoryController {
|
|||||||
categoryService.delete(userId, id);
|
categoryService.delete(userId, id);
|
||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除分类
|
||||||
|
*
|
||||||
|
* @param body 批量删除请求(分类ID列表)
|
||||||
|
* @return 实际删除的分类数
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/batch")
|
||||||
|
@Operation(summary = "批量删除分类", description = "根据分类ID列表批量删除当前用户的分类")
|
||||||
|
public Result<Integer> batchDelete(@RequestBody @Valid BatchDeleteRequest body) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
return Result.success(categoryService.batchDelete(userId, body.getIds()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
|
|
||||||
import cn.rosercode.ledgerly.common.PageResult;
|
import cn.rosercode.ledgerly.common.PageResult;
|
||||||
import cn.rosercode.ledgerly.common.Result;
|
import cn.rosercode.ledgerly.common.Result;
|
||||||
|
import cn.rosercode.ledgerly.dto.BatchDeleteRequest;
|
||||||
import cn.rosercode.ledgerly.dto.TagRequest;
|
import cn.rosercode.ledgerly.dto.TagRequest;
|
||||||
import cn.rosercode.ledgerly.service.TagService;
|
import cn.rosercode.ledgerly.service.TagService;
|
||||||
import cn.rosercode.ledgerly.util.UserContext;
|
import cn.rosercode.ledgerly.util.UserContext;
|
||||||
@@ -112,4 +113,17 @@ public class TagController {
|
|||||||
tagService.delete(userId, id);
|
tagService.delete(userId, id);
|
||||||
return Result.success();
|
return Result.success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除标签
|
||||||
|
*
|
||||||
|
* @param body 批量删除请求(标签ID列表)
|
||||||
|
* @return 实际删除的标签数
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/batch")
|
||||||
|
@Operation(summary = "批量删除标签", description = "根据标签ID列表批量删除当前用户的标签及其关联")
|
||||||
|
public Result<Integer> batchDelete(@RequestBody @Valid BatchDeleteRequest body) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
return Result.success(tagService.batchDelete(userId, body.getIds()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,4 +63,13 @@ public interface CategoryService {
|
|||||||
* @param id 分类ID
|
* @param id 分类ID
|
||||||
*/
|
*/
|
||||||
void delete(Long userId, Long id);
|
void delete(Long userId, Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除分类
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param ids 分类ID列表
|
||||||
|
* @return 实际删除的分类数
|
||||||
|
*/
|
||||||
|
int batchDelete(Long userId, List<Long> ids);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package cn.rosercode.ledgerly.service;
|
package cn.rosercode.ledgerly.service;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
@@ -28,6 +30,9 @@ import cn.rosercode.ledgerly.entity.EmailBillConfig;
|
|||||||
@Service
|
@Service
|
||||||
public class EmailBillCheckStreamService {
|
public class EmailBillCheckStreamService {
|
||||||
|
|
||||||
|
/** 进度日志行首的时间戳格式 */
|
||||||
|
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||||
|
|
||||||
private final EmailBillPuller puller;
|
private final EmailBillPuller puller;
|
||||||
private final EmailBillConfigService configService;
|
private final EmailBillConfigService configService;
|
||||||
|
|
||||||
@@ -62,7 +67,7 @@ public class EmailBillCheckStreamService {
|
|||||||
SseEmitter emitter = new SseEmitter(0L);
|
SseEmitter emitter = new SseEmitter(0L);
|
||||||
if (running.putIfAbsent(configId, Boolean.TRUE) != null) {
|
if (running.putIfAbsent(configId, Boolean.TRUE) != null) {
|
||||||
executor.submit(() -> {
|
executor.submit(() -> {
|
||||||
trySend(emitter, "line", "已有检查正在进行中,请稍候");
|
trySend(emitter, "line", timestamp() + "已有检查正在进行中,请稍候");
|
||||||
complete(emitter);
|
complete(emitter);
|
||||||
});
|
});
|
||||||
return emitter;
|
return emitter;
|
||||||
@@ -90,7 +95,7 @@ public class EmailBillCheckStreamService {
|
|||||||
trySend(emitter, "meta", "{\"configId\":" + config.getId()
|
trySend(emitter, "meta", "{\"configId\":" + config.getId()
|
||||||
+ ",\"name\":\"" + escape(config.getName()) + "\""
|
+ ",\"name\":\"" + escape(config.getName()) + "\""
|
||||||
+ ",\"host\":\"" + escape(config.getHost()) + "\"}");
|
+ ",\"host\":\"" + escape(config.getHost()) + "\"}");
|
||||||
int count = puller.pull(config, line -> trySend(emitter, "line", line));
|
int count = puller.pull(config, line -> trySend(emitter, "line", timestamp() + line));
|
||||||
trySend(emitter, "done", "{\"count\":" + count + "}");
|
trySend(emitter, "done", "{\"count\":" + count + "}");
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
log.warn("邮箱账单立即检查失败: configId={}, error={}", config.getId(), e.getMessage());
|
log.warn("邮箱账单立即检查失败: configId={}, error={}", config.getId(), e.getMessage());
|
||||||
@@ -101,6 +106,15 @@ public class EmailBillCheckStreamService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成进度日志行首的时间戳前缀,形如 {@code [21:35:26] }。
|
||||||
|
*
|
||||||
|
* @return 时间戳前缀
|
||||||
|
*/
|
||||||
|
private String timestamp() {
|
||||||
|
return "[" + LocalTime.now().format(TIME_FORMAT) + "] ";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 推送一个 SSE 事件,客户端已断开时静默失败。
|
* 推送一个 SSE 事件,客户端已断开时静默失败。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -55,4 +55,13 @@ public interface TagService {
|
|||||||
* @param id 标签ID
|
* @param id 标签ID
|
||||||
*/
|
*/
|
||||||
void delete(Long userId, Long id);
|
void delete(Long userId, Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除标签,同时清理交易与标签、规则与标签的关联
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param ids 标签ID列表
|
||||||
|
* @return 实际删除的标签数
|
||||||
|
*/
|
||||||
|
int batchDelete(Long userId, List<Long> ids);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,4 +143,31 @@ public class CategoryServiceImpl implements CategoryService {
|
|||||||
log.info("删除分类: userId={}, id={}", userId, id);
|
log.info("删除分类: userId={}, id={}", userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* 仅删除归属当前用户的分类,返回实际删除数。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public int batchDelete(Long userId, List<Long> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// 仅保留属于当前用户的分类ID
|
||||||
|
List<Long> ownedIds = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
|
||||||
|
.select(Category::getId)
|
||||||
|
.eq(Category::getUserId, userId)
|
||||||
|
.in(Category::getId, ids))
|
||||||
|
.stream()
|
||||||
|
.map(Category::getId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (ownedIds.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int deleted = categoryMapper.deleteByIds(ownedIds);
|
||||||
|
log.info("批量删除分类: userId={}, 请求={}, 实际={}", userId, ids.size(), deleted);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,4 +150,36 @@ public class TagServiceImpl implements TagService {
|
|||||||
log.info("删除标签: userId={}, id={}", userId, id);
|
log.info("删除标签: userId={}, id={}", userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* 仅删除归属当前用户的标签,先清理交易关联与规则关联再批量删除,返回实际删除数。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public int batchDelete(Long userId, List<Long> ids) {
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// 仅保留属于当前用户的标签ID
|
||||||
|
List<Long> ownedIds = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
|
||||||
|
.select(Tag::getId)
|
||||||
|
.eq(Tag::getUserId, userId)
|
||||||
|
.in(Tag::getId, ids))
|
||||||
|
.stream()
|
||||||
|
.map(Tag::getId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (ownedIds.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
// 先清理交易与标签、规则与标签的关联,再批量删除标签
|
||||||
|
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
|
||||||
|
.in(TransactionTag::getTagId, ownedIds));
|
||||||
|
ruleTagMapper.delete(new LambdaQueryWrapper<RuleTag>()
|
||||||
|
.in(RuleTag::getTagId, ownedIds));
|
||||||
|
int deleted = tagMapper.deleteByIds(ownedIds);
|
||||||
|
log.info("批量删除标签: userId={}, 请求={}, 实际={}", userId, ids.size(), deleted);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user