feat(sessions): add batch deletion (#581)

This commit is contained in:
mateaix 2026-08-10 22:13:29 +08:00
parent 197e697173
commit b196dc73fd
5 changed files with 280 additions and 10 deletions

View File

@ -13,6 +13,7 @@ import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.vo.ConversationVO;
import vip.mate.workspace.conversation.vo.MessageVO;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@ -27,6 +28,8 @@ import java.util.Map;
@RequiredArgsConstructor
public class ConversationController {
private static final int MAX_BATCH_DELETE_SIZE = 200;
private final ConversationService conversationService;
private final ChatStreamTracker streamTracker;
@ -219,13 +222,22 @@ public class ConversationController {
String username = auth != null ? auth.getName() : "anonymous";
List<String> ids = body.get("conversationIds");
if (ids == null || ids.isEmpty()) {
return R.fail("未指定要删除的会话");
return R.fail(400, "未指定要删除的会话");
}
LinkedHashSet<String> uniqueIds = new LinkedHashSet<>();
for (String id : ids) {
if (id != null && !id.isBlank()) {
uniqueIds.add(id.trim());
}
}
if (uniqueIds.isEmpty()) {
return R.fail(400, "未指定要删除的会话");
}
if (uniqueIds.size() > MAX_BATCH_DELETE_SIZE) {
return R.fail(400, "单次最多删除 " + MAX_BATCH_DELETE_SIZE + " 个会话");
}
int deleted = 0;
for (String conversationId : ids) {
if (conversationId == null || conversationId.isBlank()) {
continue;
}
for (String conversationId : uniqueIds) {
if (!conversationService.isConversationOwner(conversationId, username)) {
continue;
}

View File

@ -0,0 +1,76 @@
package vip.mate.workspace.conversation.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.Authentication;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.common.result.R;
import vip.mate.workspace.conversation.ConversationService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ConversationControllerBatchDeleteTest {
@Mock private ConversationService conversationService;
@Mock private ChatStreamTracker streamTracker;
@Mock private Authentication authentication;
private ConversationController controller;
@BeforeEach
void setUp() {
controller = new ConversationController(conversationService, streamTracker);
when(authentication.getName()).thenReturn("alice");
}
@Test
void batchDelete_deduplicatesAndTrimsIds_beforeOwnershipCheck() {
when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true);
when(conversationService.isConversationOwner("conv-2", "alice")).thenReturn(false);
R<Integer> result = controller.batchDelete(Map.of(
"conversationIds", List.of(" conv-1 ", "conv-1", "", "conv-2")), authentication);
assertEquals(1, result.getData());
verify(conversationService).isConversationOwner("conv-1", "alice");
verify(conversationService).isConversationOwner("conv-2", "alice");
verify(conversationService).deleteConversation("conv-1");
verify(conversationService, never()).deleteConversation("conv-2");
}
@Test
void batchDelete_rejectsBlankOnlyRequest() {
R<Integer> result = controller.batchDelete(Map.of(
"conversationIds", List.of("", " ")), authentication);
assertNull(result.getData());
assertEquals(400, result.getCode());
verify(conversationService, never()).isConversationOwner(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyString());
}
@Test
void batchDelete_rejectsMoreThanMaximumUniqueIds() {
List<String> ids = new ArrayList<>();
for (int i = 0; i < 201; i++) ids.add("conv-" + i);
R<Integer> result = controller.batchDelete(Map.of("conversationIds", ids), authentication);
assertNull(result.getData());
assertEquals(400, result.getCode());
verify(conversationService, never()).isConversationOwner(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyString());
}
}

View File

@ -2256,6 +2256,18 @@ export default {
deleteConfirm: 'Are you sure you want to delete this session?',
deleteTitle: 'Confirm Delete',
deleteFailed: 'Failed to delete session',
selectAll: 'Select all sessions on this page',
deselectAll: 'Deselect all',
selectSession: 'Select session "{title}"',
selectedCount: '{count} sessions selected',
clearSelection: 'Clear selection',
batchDelete: 'Delete selected',
deleting: 'Deleting...',
batchDeleteTitle: 'Confirm Bulk Delete',
batchDeleteConfirm: 'Delete the {count} selected sessions? This action cannot be undone.',
batchDeleteSuccess: 'Deleted {count} sessions',
batchDeletePartial: 'Deleted {deleted} of {total} sessions. The others may no longer exist or may not be accessible.',
batchDeleteFailed: 'Failed to delete selected sessions',
switchModel: 'Switch the model used for this conversation',
modelSwitched: 'Model switched',
modelSwitchFailed: 'Failed to switch model',

View File

@ -2130,6 +2130,18 @@ export default {
deleteConfirm: '确定要删除这个会话吗?',
deleteTitle: '确认删除',
deleteFailed: '删除会话失败',
selectAll: '选中当前页全部会话',
deselectAll: '取消全选',
selectSession: '选中会话“{title}”',
selectedCount: '已选 {count} 个会话',
clearSelection: '取消选择',
batchDelete: '批量删除',
deleting: '删除中...',
batchDeleteTitle: '确认批量删除',
batchDeleteConfirm: '确定要删除选中的 {count} 个会话吗?此操作不可撤销。',
batchDeleteSuccess: '已删除 {count} 个会话',
batchDeletePartial: '已删除 {deleted}/{total} 个会话,其余会话可能已不存在或无权操作',
batchDeleteFailed: '批量删除会话失败',
switchModel: '切换该会话使用的模型',
modelSwitched: '已切换会话模型',
modelSwitchFailed: '切换模型失败',

View File

@ -24,10 +24,34 @@
<!-- 会话列表 -->
<div class="sessions-table-wrap">
<div v-if="selectedSessionIds.length > 0" class="selection-toolbar" role="status">
<span>{{ t('sessions.selectedCount', { count: selectedSessionIds.length }) }}</span>
<div class="selection-actions">
<button class="selection-clear" :disabled="batchDeleting" @click="clearSelection">
{{ t('sessions.clearSelection') }}
</button>
<button class="selection-delete" :disabled="batchDeleting" @click="batchDeleteSessions">
<span v-if="batchDeleting" class="selection-spinner" aria-hidden="true"></span>
{{ batchDeleting ? t('sessions.deleting') : t('sessions.batchDelete') }}
</button>
</div>
</div>
<div class="sessions-table-scroll">
<table class="sessions-table">
<thead>
<tr>
<th class="selection-cell">
<input
type="checkbox"
class="session-checkbox"
:checked="allVisibleSelected"
:indeterminate="someVisibleSelected"
:disabled="sessions.length === 0 || batchDeleting"
:aria-label="t('sessions.selectAll')"
:title="allVisibleSelected ? t('sessions.deselectAll') : t('sessions.selectAll')"
@change="toggleSelectAll"
/>
</th>
<th>{{ t('sessions.columns.session') }}</th>
<th>{{ t('sessions.columns.source') }}</th>
<th>{{ t('sessions.columns.agent') }}</th>
@ -39,7 +63,22 @@
</tr>
</thead>
<tbody>
<tr v-for="session in sessions" :key="session.conversationId" class="session-row">
<tr
v-for="session in sessions"
:key="session.conversationId"
class="session-row"
:class="{ 'is-selected': isSelected(session.conversationId) }"
>
<td class="selection-cell">
<input
type="checkbox"
class="session-checkbox"
:checked="isSelected(session.conversationId)"
:disabled="batchDeleting"
:aria-label="t('sessions.selectSession', { title: session.title || session.conversationId })"
@change="toggleSessionSelection(session.conversationId)"
/>
</td>
<td>
<div class="session-info">
<div class="session-title">{{ session.title }}</div>
@ -107,7 +146,7 @@
</td>
</tr>
<tr v-if="sessions.length === 0">
<td colspan="8" class="empty-row">
<td colspan="9" class="empty-row">
<div class="empty-state">
<div class="empty-icon-ring">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
@ -144,7 +183,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
@ -163,6 +202,14 @@ const searchText = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const selectedSessionIds = ref<string[]>([])
const batchDeleting = ref(false)
const visibleSessionIds = computed(() => sessions.value.map(session => session.conversationId))
const allVisibleSelected = computed(() => visibleSessionIds.value.length > 0
&& visibleSessionIds.value.every(id => selectedSessionIds.value.includes(id)))
const someVisibleSelected = computed(() => !allVisibleSelected.value
&& visibleSessionIds.value.some(id => selectedSessionIds.value.includes(id)))
// Per-conversation model selection state (closes #183). Loaded once on mount,
// not per-row, because providers don't change during a session-list view.
@ -207,6 +254,7 @@ async function loadSessions() {
const body = res.data || {}
sessions.value = body.records || []
total.value = Number(body.total) || 0
clearSelection()
} catch (e: any) { mcToast.error(t('sessions.loadFailed')) }
}
@ -234,10 +282,62 @@ async function deleteSession(conversationId: string) {
if (!ok) return
try {
await conversationApi.delete(conversationId)
await loadSessions()
await reloadAfterDelete(1)
} catch (e: any) { mcToast.error(t('sessions.deleteFailed')) }
}
function isSelected(conversationId: string) {
return selectedSessionIds.value.includes(conversationId)
}
function toggleSessionSelection(conversationId: string) {
selectedSessionIds.value = isSelected(conversationId)
? selectedSessionIds.value.filter(id => id !== conversationId)
: [...selectedSessionIds.value, conversationId]
}
function toggleSelectAll() {
selectedSessionIds.value = allVisibleSelected.value ? [] : [...visibleSessionIds.value]
}
function clearSelection() {
selectedSessionIds.value = []
}
async function reloadAfterDelete(deletedCount: number) {
const remainingTotal = Math.max(0, total.value - deletedCount)
const lastPage = Math.max(1, Math.ceil(remainingTotal / pageSize.value))
if (currentPage.value > lastPage) currentPage.value = lastPage
await loadSessions()
}
async function batchDeleteSessions() {
const ids = [...selectedSessionIds.value]
if (ids.length === 0 || batchDeleting.value) return
const ok = await mcConfirm({
title: t('sessions.batchDeleteTitle'),
message: t('sessions.batchDeleteConfirm', { count: ids.length }),
tone: 'danger',
})
if (!ok) return
batchDeleting.value = true
try {
const res: any = await conversationApi.batchDelete(ids)
const deleted = Number(res.data) || 0
if (deleted === ids.length) {
mcToast.success(t('sessions.batchDeleteSuccess', { count: deleted }))
} else {
mcToast.warning(t('sessions.batchDeletePartial', { deleted, total: ids.length }))
}
await reloadAfterDelete(deleted)
} catch (e: any) {
mcToast.error(e?.message || t('sessions.batchDeleteFailed'))
} finally {
batchDeleting.value = false
}
}
// ==================== Model selection (#183) ====================
/** Composite key consumed by ModelSelector: "{providerId}::{modelName}". */
@ -442,6 +542,53 @@ function formatTime(time?: string) {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.selection-toolbar {
min-height: 52px;
padding: 9px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--mc-text-secondary);
font-size: 13px;
font-weight: 600;
background: var(--mc-primary-bg);
border-bottom: 1px solid var(--mc-border);
}
.selection-actions { display: flex; align-items: center; gap: 8px; }
.selection-clear,
.selection-delete {
min-height: 32px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--mc-border);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.selection-clear { color: var(--mc-text-secondary); background: var(--mc-bg-elevated); }
.selection-clear:hover:not(:disabled) { color: var(--mc-primary); border-color: var(--mc-primary); }
.selection-delete {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--mc-text-inverse);
background: var(--mc-danger);
border-color: var(--mc-danger);
}
.selection-delete:hover:not(:disabled) { background: var(--mc-danger-hover); border-color: var(--mc-danger-hover); }
.selection-clear:disabled,
.selection-delete:disabled { opacity: 0.6; cursor: not-allowed; }
.selection-spinner {
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: selection-spin 0.7s linear infinite;
}
@keyframes selection-spin { to { transform: rotate(360deg); } }
/* Inner scroller takes the horizontal overflow so the outer wrap can keep
`overflow: hidden` that's what makes the rounded corners actually clip
the th background instead of letting it bleed into the top corners. */
@ -451,7 +598,17 @@ function formatTime(time?: string) {
if they truly need to (session title / id is the only multi-line cell). */
.sessions-table th,
.sessions-table td { white-space: nowrap; }
.sessions-table td:first-child { white-space: normal; }
.sessions-table td:nth-child(2) { white-space: normal; }
.selection-cell { width: 44px; padding-left: 18px !important; padding-right: 4px !important; }
.session-checkbox {
width: 16px;
height: 16px;
display: block;
margin: 0;
accent-color: var(--mc-primary);
cursor: pointer;
}
.session-checkbox:disabled { cursor: not-allowed; opacity: 0.55; }
.sessions-table th {
padding: 14px 18px;
text-align: left;
@ -465,6 +622,7 @@ function formatTime(time?: string) {
}
.session-row { border-bottom: 1px solid var(--mc-border-light); transition: background 0.12s; }
.session-row:hover { background: var(--mc-bg-muted); }
.session-row.is-selected { background: var(--mc-primary-bg); }
.session-row:last-child { border-bottom: none; }
.sessions-table td { padding: 16px 18px; font-size: 14px; color: var(--mc-text-primary); }
.session-title { font-weight: 600; color: var(--mc-text-primary); margin-bottom: 3px; letter-spacing: -0.005em; }