mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(sessions): add batch deletion (#581)
This commit is contained in:
parent
197e697173
commit
b196dc73fd
@ -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;
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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: '切换模型失败',
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user