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"
@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController;
import cn.rosercode.ledgerly.common.PageResult;
import cn.rosercode.ledgerly.common.Result;
import cn.rosercode.ledgerly.dto.BatchDeleteRequest;
import cn.rosercode.ledgerly.dto.CategoryRequest;
import cn.rosercode.ledgerly.service.CategoryService;
import cn.rosercode.ledgerly.util.UserContext;
@@ -116,4 +117,17 @@ public class CategoryController {
categoryService.delete(userId, id);
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.Result;
import cn.rosercode.ledgerly.dto.BatchDeleteRequest;
import cn.rosercode.ledgerly.dto.TagRequest;
import cn.rosercode.ledgerly.service.TagService;
import cn.rosercode.ledgerly.util.UserContext;
@@ -112,4 +113,17 @@ public class TagController {
tagService.delete(userId, id);
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
*/
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;
import java.io.IOException;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
@@ -28,6 +30,9 @@ import cn.rosercode.ledgerly.entity.EmailBillConfig;
@Service
public class EmailBillCheckStreamService {
/** 进度日志行首的时间戳格式 */
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
private final EmailBillPuller puller;
private final EmailBillConfigService configService;
@@ -62,7 +67,7 @@ public class EmailBillCheckStreamService {
SseEmitter emitter = new SseEmitter(0L);
if (running.putIfAbsent(configId, Boolean.TRUE) != null) {
executor.submit(() -> {
trySend(emitter, "line", "已有检查正在进行中,请稍候");
trySend(emitter, "line", timestamp() + "已有检查正在进行中,请稍候");
complete(emitter);
});
return emitter;
@@ -90,7 +95,7 @@ public class EmailBillCheckStreamService {
trySend(emitter, "meta", "{\"configId\":" + config.getId()
+ ",\"name\":\"" + escape(config.getName()) + "\""
+ ",\"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 + "}");
} catch (RuntimeException e) {
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 事件,客户端已断开时静默失败。
*
@@ -55,4 +55,13 @@ public interface TagService {
* @param id 标签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);
}
/**
* {@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);
}
/**
* {@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;
}
}