mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +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.ConversationVO;
|
||||||
import vip.mate.workspace.conversation.vo.MessageVO;
|
import vip.mate.workspace.conversation.vo.MessageVO;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -27,6 +28,8 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ConversationController {
|
public class ConversationController {
|
||||||
|
|
||||||
|
private static final int MAX_BATCH_DELETE_SIZE = 200;
|
||||||
|
|
||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
private final ChatStreamTracker streamTracker;
|
private final ChatStreamTracker streamTracker;
|
||||||
|
|
||||||
@ -219,13 +222,22 @@ public class ConversationController {
|
|||||||
String username = auth != null ? auth.getName() : "anonymous";
|
String username = auth != null ? auth.getName() : "anonymous";
|
||||||
List<String> ids = body.get("conversationIds");
|
List<String> ids = body.get("conversationIds");
|
||||||
if (ids == null || ids.isEmpty()) {
|
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;
|
int deleted = 0;
|
||||||
for (String conversationId : ids) {
|
for (String conversationId : uniqueIds) {
|
||||||
if (conversationId == null || conversationId.isBlank()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!conversationService.isConversationOwner(conversationId, username)) {
|
if (!conversationService.isConversationOwner(conversationId, username)) {
|
||||||
continue;
|
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?',
|
deleteConfirm: 'Are you sure you want to delete this session?',
|
||||||
deleteTitle: 'Confirm Delete',
|
deleteTitle: 'Confirm Delete',
|
||||||
deleteFailed: 'Failed to delete session',
|
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',
|
switchModel: 'Switch the model used for this conversation',
|
||||||
modelSwitched: 'Model switched',
|
modelSwitched: 'Model switched',
|
||||||
modelSwitchFailed: 'Failed to switch model',
|
modelSwitchFailed: 'Failed to switch model',
|
||||||
|
|||||||
@ -2130,6 +2130,18 @@ export default {
|
|||||||
deleteConfirm: '确定要删除这个会话吗?',
|
deleteConfirm: '确定要删除这个会话吗?',
|
||||||
deleteTitle: '确认删除',
|
deleteTitle: '确认删除',
|
||||||
deleteFailed: '删除会话失败',
|
deleteFailed: '删除会话失败',
|
||||||
|
selectAll: '选中当前页全部会话',
|
||||||
|
deselectAll: '取消全选',
|
||||||
|
selectSession: '选中会话“{title}”',
|
||||||
|
selectedCount: '已选 {count} 个会话',
|
||||||
|
clearSelection: '取消选择',
|
||||||
|
batchDelete: '批量删除',
|
||||||
|
deleting: '删除中...',
|
||||||
|
batchDeleteTitle: '确认批量删除',
|
||||||
|
batchDeleteConfirm: '确定要删除选中的 {count} 个会话吗?此操作不可撤销。',
|
||||||
|
batchDeleteSuccess: '已删除 {count} 个会话',
|
||||||
|
batchDeletePartial: '已删除 {deleted}/{total} 个会话,其余会话可能已不存在或无权操作',
|
||||||
|
batchDeleteFailed: '批量删除会话失败',
|
||||||
switchModel: '切换该会话使用的模型',
|
switchModel: '切换该会话使用的模型',
|
||||||
modelSwitched: '已切换会话模型',
|
modelSwitched: '已切换会话模型',
|
||||||
modelSwitchFailed: '切换模型失败',
|
modelSwitchFailed: '切换模型失败',
|
||||||
|
|||||||
@ -24,10 +24,34 @@
|
|||||||
|
|
||||||
<!-- 会话列表 -->
|
<!-- 会话列表 -->
|
||||||
<div class="sessions-table-wrap">
|
<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">
|
<div class="sessions-table-scroll">
|
||||||
<table class="sessions-table">
|
<table class="sessions-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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.session') }}</th>
|
||||||
<th>{{ t('sessions.columns.source') }}</th>
|
<th>{{ t('sessions.columns.source') }}</th>
|
||||||
<th>{{ t('sessions.columns.agent') }}</th>
|
<th>{{ t('sessions.columns.agent') }}</th>
|
||||||
@ -39,7 +63,22 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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>
|
<td>
|
||||||
<div class="session-info">
|
<div class="session-info">
|
||||||
<div class="session-title">{{ session.title }}</div>
|
<div class="session-title">{{ session.title }}</div>
|
||||||
@ -107,7 +146,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="sessions.length === 0">
|
<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-state">
|
||||||
<div class="empty-icon-ring">
|
<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">
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mcToast } from '@/composables/useMcToast'
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
@ -163,6 +202,14 @@ const searchText = ref('')
|
|||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const total = ref(0)
|
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,
|
// Per-conversation model selection state (closes #183). Loaded once on mount,
|
||||||
// not per-row, because providers don't change during a session-list view.
|
// not per-row, because providers don't change during a session-list view.
|
||||||
@ -207,6 +254,7 @@ async function loadSessions() {
|
|||||||
const body = res.data || {}
|
const body = res.data || {}
|
||||||
sessions.value = body.records || []
|
sessions.value = body.records || []
|
||||||
total.value = Number(body.total) || 0
|
total.value = Number(body.total) || 0
|
||||||
|
clearSelection()
|
||||||
} catch (e: any) { mcToast.error(t('sessions.loadFailed')) }
|
} catch (e: any) { mcToast.error(t('sessions.loadFailed')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,10 +282,62 @@ async function deleteSession(conversationId: string) {
|
|||||||
if (!ok) return
|
if (!ok) return
|
||||||
try {
|
try {
|
||||||
await conversationApi.delete(conversationId)
|
await conversationApi.delete(conversationId)
|
||||||
await loadSessions()
|
await reloadAfterDelete(1)
|
||||||
} catch (e: any) { mcToast.error(t('sessions.deleteFailed')) }
|
} 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) ====================
|
// ==================== Model selection (#183) ====================
|
||||||
|
|
||||||
/** Composite key consumed by ModelSelector: "{providerId}::{modelName}". */
|
/** Composite key consumed by ModelSelector: "{providerId}::{modelName}". */
|
||||||
@ -442,6 +542,53 @@ function formatTime(time?: string) {
|
|||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-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
|
/* Inner scroller takes the horizontal overflow so the outer wrap can keep
|
||||||
`overflow: hidden` — that's what makes the rounded corners actually clip
|
`overflow: hidden` — that's what makes the rounded corners actually clip
|
||||||
the th background instead of letting it bleed into the top corners. */
|
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). */
|
if they truly need to (session title / id is the only multi-line cell). */
|
||||||
.sessions-table th,
|
.sessions-table th,
|
||||||
.sessions-table td { white-space: nowrap; }
|
.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 {
|
.sessions-table th {
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
text-align: left;
|
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 { border-bottom: 1px solid var(--mc-border-light); transition: background 0.12s; }
|
||||||
.session-row:hover { background: var(--mc-bg-muted); }
|
.session-row:hover { background: var(--mc-bg-muted); }
|
||||||
|
.session-row.is-selected { background: var(--mc-primary-bg); }
|
||||||
.session-row:last-child { border-bottom: none; }
|
.session-row:last-child { border-bottom: none; }
|
||||||
.sessions-table td { padding: 16px 18px; font-size: 14px; color: var(--mc-text-primary); }
|
.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; }
|
.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