mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(chat): pin, multi-select delete and agent filter for conversations (#144)
This commit is contained in:
parent
18df97a8e6
commit
7fb47390af
@ -94,6 +94,7 @@ public class ConversationService {
|
||||
LambdaQueryWrapper<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
|
||||
.in(ConversationEntity::getUsername, username, SYSTEM_USER)
|
||||
.isNull(ConversationEntity::getParentConversationId)
|
||||
.orderByDesc(ConversationEntity::getPinned)
|
||||
.orderByDesc(ConversationEntity::getLastActiveTime);
|
||||
if (workspaceId != null) {
|
||||
wrapper.eq(ConversationEntity::getWorkspaceId, workspaceId);
|
||||
@ -322,6 +323,19 @@ public class ConversationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin or unpin a conversation. Pinned conversations sort ahead of unpinned
|
||||
* ones in the sidebar list regardless of last-active time.
|
||||
*/
|
||||
public void setPinned(String conversationId, boolean pinned) {
|
||||
ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper<ConversationEntity>()
|
||||
.eq(ConversationEntity::getConversationId, conversationId));
|
||||
if (conv != null) {
|
||||
conv.setPinned(pinned ? 1 : 0);
|
||||
conversationMapper.updateById(conv);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话的流状态(running / idle)
|
||||
*/
|
||||
|
||||
@ -125,6 +125,47 @@ public class ConversationController {
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 置顶 / 取消置顶会话
|
||||
*/
|
||||
@Operation(summary = "置顶或取消置顶会话")
|
||||
@PutMapping("/{conversationId}/pin")
|
||||
public R<Void> setPinned(@PathVariable String conversationId,
|
||||
@RequestBody Map<String, Boolean> body,
|
||||
Authentication auth) {
|
||||
String username = auth != null ? auth.getName() : "anonymous";
|
||||
if (!conversationService.isConversationOwner(conversationId, username)) {
|
||||
return R.fail("无权操作该会话");
|
||||
}
|
||||
conversationService.setPinned(conversationId, Boolean.TRUE.equals(body.get("pinned")));
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除会话(仅删除当前用户有权操作的会话)
|
||||
*/
|
||||
@Operation(summary = "批量删除会话")
|
||||
@PostMapping("/batch-delete")
|
||||
public R<Integer> batchDelete(@RequestBody Map<String, List<String>> body, Authentication auth) {
|
||||
String username = auth != null ? auth.getName() : "anonymous";
|
||||
List<String> ids = body.get("conversationIds");
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return R.fail("未指定要删除的会话");
|
||||
}
|
||||
int deleted = 0;
|
||||
for (String conversationId : ids) {
|
||||
if (conversationId == null || conversationId.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (!conversationService.isConversationOwner(conversationId, username)) {
|
||||
continue;
|
||||
}
|
||||
conversationService.deleteConversation(conversationId);
|
||||
deleted++;
|
||||
}
|
||||
return R.ok(deleted);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空会话消息(保留会话记录)
|
||||
*/
|
||||
|
||||
@ -48,6 +48,9 @@ public class ConversationEntity {
|
||||
/** 父会话 ID(委派场景下,子会话记录其父会话的 conversationId) */
|
||||
private String parentConversationId;
|
||||
|
||||
/** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */
|
||||
private Integer pinned;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -64,6 +64,8 @@ public class ConversationVO extends ConversationEntity {
|
||||
vo.setMessageCount(entity.getMessageCount());
|
||||
vo.setLastMessage(entity.getLastMessage());
|
||||
vo.setLastActiveTime(entity.getLastActiveTime());
|
||||
vo.setWorkspaceId(entity.getWorkspaceId());
|
||||
vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0);
|
||||
vo.setCreateTime(entity.getCreateTime());
|
||||
vo.setUpdateTime(entity.getUpdateTime());
|
||||
// 补充关联字段
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
-- Per-conversation pin flag. A pinned conversation sorts ahead of unpinned
|
||||
-- ones in the sidebar list regardless of last-active time, so users can keep
|
||||
-- important conversations reachable in one glance. 0 = normal, 1 = pinned.
|
||||
|
||||
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS pinned INT DEFAULT 0;
|
||||
@ -0,0 +1,8 @@
|
||||
-- Per-conversation pin flag. See the h2 sibling for the rationale.
|
||||
-- MySQL has no ADD COLUMN IF NOT EXISTS; guard via INFORMATION_SCHEMA.
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_conversation'
|
||||
AND COLUMN_NAME = 'pinned');
|
||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_conversation ADD COLUMN pinned INT DEFAULT 0', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@ -164,6 +164,10 @@ export const conversationApi = {
|
||||
http.delete(`/conversations/${conversationId}/messages`),
|
||||
rename: (conversationId: string, title: string) =>
|
||||
http.put(`/conversations/${conversationId}/title`, { title }),
|
||||
setPinned: (conversationId: string, pinned: boolean) =>
|
||||
http.put(`/conversations/${conversationId}/pin`, { pinned }),
|
||||
batchDelete: (conversationIds: string[]) =>
|
||||
http.post('/conversations/batch-delete', { conversationIds }),
|
||||
}
|
||||
|
||||
// ==================== Skill ====================
|
||||
|
||||
@ -254,6 +254,20 @@ export default {
|
||||
dateLast7Days: 'Last 7 Days',
|
||||
dateEarlier: 'Earlier',
|
||||
hasUnread: 'New activity',
|
||||
filterByAgent: 'Filter by employee',
|
||||
allAgents: 'All employees',
|
||||
pin: 'Pin',
|
||||
unpin: 'Unpin',
|
||||
rename: 'Rename',
|
||||
pinFailed: 'Failed to update pin',
|
||||
selectMode: 'Select',
|
||||
exitSelectMode: 'Exit selection',
|
||||
selectAll: 'Select all',
|
||||
deselectAll: 'Deselect all',
|
||||
selectedCount: '{count} selected',
|
||||
batchDelete: 'Delete selected',
|
||||
batchDeleteConfirm: 'Delete the {count} selected conversations? This cannot be undone.',
|
||||
batchDeleteFailed: 'Batch delete failed',
|
||||
cronRunning: {
|
||||
executing: 'Executing…',
|
||||
fallbackName: 'Scheduled task',
|
||||
|
||||
@ -254,6 +254,20 @@ export default {
|
||||
dateLast7Days: '近 7 天',
|
||||
dateEarlier: '更早',
|
||||
hasUnread: '有新内容',
|
||||
filterByAgent: '按员工筛选',
|
||||
allAgents: '全部员工',
|
||||
pin: '置顶',
|
||||
unpin: '取消置顶',
|
||||
rename: '重命名',
|
||||
pinFailed: '置顶操作失败',
|
||||
selectMode: '多选',
|
||||
exitSelectMode: '退出多选',
|
||||
selectAll: '全选',
|
||||
deselectAll: '取消全选',
|
||||
selectedCount: '已选 {count} 项',
|
||||
batchDelete: '删除选中',
|
||||
batchDeleteConfirm: '确定删除选中的 {count} 个会话?此操作不可恢复。',
|
||||
batchDeleteFailed: '批量删除失败',
|
||||
cronRunning: {
|
||||
executing: '执行中…',
|
||||
fallbackName: '定时任务',
|
||||
|
||||
@ -65,6 +65,7 @@ export interface Conversation {
|
||||
status?: 'active' | 'closed'
|
||||
streamStatus?: 'idle' | 'running'
|
||||
source?: string
|
||||
pinned?: number
|
||||
lastActiveTime?: string
|
||||
updateTime?: string
|
||||
createTime?: string
|
||||
|
||||
@ -14,9 +14,20 @@
|
||||
<div class="panel-kicker">{{ $t('nav.chat') }}</div>
|
||||
<h2 class="panel-title">{{ $t('chat.conversations') }}</h2>
|
||||
</div>
|
||||
<button class="new-chat-btn" @click="newConversation" :title="`${$t('chat.newChat')} (⌘N)`">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</button>
|
||||
<div class="panel-header-actions">
|
||||
<button
|
||||
v-if="(!convPanelCollapsed || isMobile) && conversations.length > 0"
|
||||
class="panel-icon-btn"
|
||||
:class="{ active: selectMode }"
|
||||
@click="toggleSelectMode"
|
||||
:title="selectMode ? $t('chat.exitSelectMode') : $t('chat.selectMode')"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||||
</button>
|
||||
<button class="new-chat-btn" @click="newConversation" :title="`${$t('chat.newChat')} (⌘N)`">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 折叠切换按钮 -->
|
||||
<button v-if="!isMobile" class="conv-collapse-btn" @click="toggleConvPanel" :title="convPanelCollapsed ? $t('common.expandSidebar') : $t('common.collapseSidebar')">
|
||||
@ -35,6 +46,16 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="(!convPanelCollapsed || isMobile) && agentFilterOptions.length > 1"
|
||||
class="conv-filter"
|
||||
>
|
||||
<select v-model="convAgentFilter" class="conv-filter-select">
|
||||
<option value="">{{ $t('chat.allAgents') }}</option>
|
||||
<option v-for="opt in agentFilterOptions" :key="opt.id" :value="opt.id">{{ opt.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="conversation-list">
|
||||
<template v-for="group in groupedConversations" :key="group.label">
|
||||
<div v-if="!convPanelCollapsed || isMobile" class="conv-group-title">{{ group.label }}</div>
|
||||
@ -43,11 +64,23 @@
|
||||
:key="conv.conversationId"
|
||||
class="conv-item"
|
||||
:class="{
|
||||
active: currentConversationId === conv.conversationId,
|
||||
active: !selectMode && currentConversationId === conv.conversationId,
|
||||
'is-running': conv.streamStatus === 'running',
|
||||
'is-selected': selectMode && selectedConvIds.includes(conv.conversationId),
|
||||
}"
|
||||
@click="selectConversation(conv)"
|
||||
@click="onConvClick(conv)"
|
||||
>
|
||||
<label
|
||||
v-if="selectMode && (!convPanelCollapsed || isMobile)"
|
||||
class="conv-checkbox"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedConvIds.includes(conv.conversationId)"
|
||||
@change="toggleConvSelection(conv)"
|
||||
/>
|
||||
</label>
|
||||
<div class="conv-icon">
|
||||
<img :src="channelIconUrl(conv.source)" width="14" height="14" alt="" />
|
||||
<span
|
||||
@ -94,9 +127,22 @@
|
||||
<span>{{ formatConversationTime(conv.lastActiveTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!convPanelCollapsed || isMobile" class="conv-delete" @click.stop="confirmDeleteConversation(conv.conversationId)" :title="$t('common.delete')">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
<div v-if="!selectMode && (!convPanelCollapsed || isMobile)" class="conv-actions">
|
||||
<button
|
||||
class="conv-action"
|
||||
:class="{ pinned: conv.pinned }"
|
||||
@click.stop="togglePin(conv)"
|
||||
:title="conv.pinned ? $t('chat.unpin') : $t('chat.pin')"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><line x1="12" y1="17" x2="12" y2="22"/><path d="M9 10.76V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v5.76a2 2 0 0 0 .59 1.41L17 14H7l1.41-1.83A2 2 0 0 0 9 10.76z"/></svg>
|
||||
</button>
|
||||
<button class="conv-action" @click.stop="startRename(conv)" :title="$t('chat.rename')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button class="conv-action danger" @click.stop="confirmDeleteConversation(conv.conversationId)" :title="$t('common.delete')">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -104,6 +150,23 @@
|
||||
<p>{{ $t('chat.noConversations') }}</p>
|
||||
<p>{{ $t('chat.startNewChat') }}</p>
|
||||
</div>
|
||||
<div v-else-if="groupedConversations.length === 0" class="empty-convs">
|
||||
<p>{{ $t('chat.noConversations') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectMode && (!convPanelCollapsed || isMobile)" class="conv-select-bar">
|
||||
<button class="conv-select-all" @click="toggleSelectAll">
|
||||
{{ allVisibleSelected ? $t('chat.deselectAll') : $t('chat.selectAll') }}
|
||||
</button>
|
||||
<span class="conv-select-count">{{ $t('chat.selectedCount', { count: selectedConvIds.length }) }}</span>
|
||||
<button
|
||||
class="conv-batch-delete"
|
||||
:disabled="selectedConvIds.length === 0"
|
||||
@click="batchDeleteSelected"
|
||||
>
|
||||
{{ $t('chat.batchDelete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -518,6 +581,66 @@ async function confirmDeleteConversation(conversationId: string) {
|
||||
if (ok) deleteConversation(conversationId)
|
||||
}
|
||||
|
||||
// Filter the sidebar list down to a single agent's conversations.
|
||||
const convAgentFilter = ref('')
|
||||
|
||||
// Multi-select mode for batch deletion.
|
||||
const selectMode = ref(false)
|
||||
const selectedConvIds = ref<string[]>([])
|
||||
|
||||
function toggleSelectMode() {
|
||||
selectMode.value = !selectMode.value
|
||||
selectedConvIds.value = []
|
||||
}
|
||||
|
||||
function toggleConvSelection(conv: Conversation) {
|
||||
const id = conv.conversationId
|
||||
const idx = selectedConvIds.value.indexOf(id)
|
||||
if (idx >= 0) selectedConvIds.value.splice(idx, 1)
|
||||
else selectedConvIds.value.push(id)
|
||||
}
|
||||
|
||||
function onConvClick(conv: Conversation) {
|
||||
if (selectMode.value) toggleConvSelection(conv)
|
||||
else selectConversation(conv)
|
||||
}
|
||||
|
||||
async function togglePin(conv: Conversation) {
|
||||
const next = !conv.pinned
|
||||
conv.pinned = next ? 1 : 0
|
||||
try {
|
||||
await conversationApi.setPinned(conv.conversationId, next)
|
||||
await loadConversations()
|
||||
} catch {
|
||||
conv.pinned = next ? 0 : 1
|
||||
mcToast.error(t('chat.pinFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function batchDeleteSelected() {
|
||||
const ids = [...selectedConvIds.value]
|
||||
if (ids.length === 0) return
|
||||
const ok = await mcConfirm({
|
||||
title: t('common.confirm'),
|
||||
message: t('chat.batchDeleteConfirm', { count: ids.length }),
|
||||
tone: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await conversationApi.batchDelete(ids)
|
||||
conversations.value = conversations.value.filter(c => !ids.includes(c.conversationId))
|
||||
if (ids.includes(currentConversationId.value)) {
|
||||
resetStreamingState()
|
||||
messages.value = []
|
||||
currentConversationId.value = ''
|
||||
}
|
||||
selectedConvIds.value = []
|
||||
selectMode.value = false
|
||||
} catch {
|
||||
mcToast.error(t('chat.batchDeleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽上传
|
||||
const isDragging = ref(false)
|
||||
let dragCounter = 0
|
||||
@ -766,10 +889,9 @@ const groupedConversations = computed(() => {
|
||||
const yesterdayStart = todayStart - 86400000
|
||||
const last7Start = todayStart - 7 * 86400000
|
||||
|
||||
// Pinned group always sits at the top so the unified cron output (tasks_<wsId>)
|
||||
// is reachable in one glance even after a busy day pushes other conversations
|
||||
// ahead of it. Only the unified-cron conversation pattern is pinned for now;
|
||||
// we'll generalize when we have other always-visible conversations.
|
||||
// Pinned group sits at the top: the unified cron output (tasks_<wsId>) plus
|
||||
// any conversation the user has explicitly pinned, so important threads stay
|
||||
// reachable in one glance even after a busy day pushes others ahead.
|
||||
const pinned: Conversation[] = []
|
||||
const groups: { label: string; items: Conversation[] }[] = [
|
||||
{ label: t('chat.datePinned', '置顶'), items: pinned },
|
||||
@ -779,8 +901,10 @@ const groupedConversations = computed(() => {
|
||||
{ label: t('chat.dateEarlier'), items: [] },
|
||||
]
|
||||
|
||||
const agentFilter = convAgentFilter.value
|
||||
for (const conv of conversations.value) {
|
||||
if (conv.conversationId && conv.conversationId.startsWith('tasks_')) {
|
||||
if (agentFilter && String(conv.agentId ?? '') !== agentFilter) continue
|
||||
if ((conv.conversationId && conv.conversationId.startsWith('tasks_')) || conv.pinned) {
|
||||
pinned.push(conv)
|
||||
continue
|
||||
}
|
||||
@ -794,6 +918,29 @@ const groupedConversations = computed(() => {
|
||||
return groups.filter(g => g.items.length > 0)
|
||||
})
|
||||
|
||||
// Distinct agents present in the conversation list — drives the agent filter
|
||||
// dropdown. Hidden when fewer than two agents have conversations.
|
||||
const agentFilterOptions = computed(() => {
|
||||
const seen = new Map<string, string>()
|
||||
for (const conv of conversations.value) {
|
||||
if (conv.agentId == null || conv.agentId === '') continue
|
||||
const id = String(conv.agentId)
|
||||
if (!seen.has(id)) seen.set(id, conv.agentName || id)
|
||||
}
|
||||
return [...seen.entries()].map(([id, name]) => ({ id, name }))
|
||||
})
|
||||
|
||||
const visibleConvIds = computed(() =>
|
||||
groupedConversations.value.flatMap(g => g.items.map(i => i.conversationId)))
|
||||
|
||||
const allVisibleSelected = computed(() =>
|
||||
visibleConvIds.value.length > 0 &&
|
||||
visibleConvIds.value.every(id => selectedConvIds.value.includes(id)))
|
||||
|
||||
function toggleSelectAll() {
|
||||
selectedConvIds.value = allVisibleSelected.value ? [] : [...visibleConvIds.value]
|
||||
}
|
||||
|
||||
const currentRuntimeModel = computed(() => {
|
||||
if (defaultModel.value?.name && defaultModel.value?.modelName) {
|
||||
return `${defaultModel.value.name} (${defaultModel.value.modelName})`
|
||||
@ -2188,6 +2335,7 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
}
|
||||
|
||||
.conv-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@ -2206,7 +2354,7 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
background: var(--mc-primary-bg);
|
||||
}
|
||||
|
||||
.conv-item:hover .conv-delete {
|
||||
.conv-item:hover .conv-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@ -2338,8 +2486,29 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.15);
|
||||
}
|
||||
|
||||
.conv-delete {
|
||||
/* Actions overlay the right edge of the row so they reserve no layout width —
|
||||
the conversation title keeps its full space. A left-fading gradient masks
|
||||
the meta text behind them when the row is hovered. */
|
||||
.conv-actions {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding-left: 16px;
|
||||
opacity: 0;
|
||||
background: linear-gradient(to right, transparent, var(--mc-bg-sunken) 42%);
|
||||
border-radius: 12px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.conv-item.active .conv-actions {
|
||||
background: linear-gradient(to right, transparent, var(--mc-primary-bg) 42%);
|
||||
}
|
||||
|
||||
.conv-action {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
@ -2355,11 +2524,147 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.conv-delete:hover {
|
||||
.conv-action:hover {
|
||||
background: var(--mc-bg-elevated);
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
|
||||
.conv-action.danger:hover {
|
||||
background: var(--mc-danger-bg);
|
||||
color: var(--mc-danger);
|
||||
}
|
||||
|
||||
/* The pin button shows the accent color while a conversation is pinned, so the
|
||||
filled pin icon reads as "active" once the row is hovered. */
|
||||
.conv-action.pinned {
|
||||
color: var(--mc-accent);
|
||||
}
|
||||
|
||||
/* Agent filter dropdown above the conversation list. */
|
||||
.conv-filter {
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
|
||||
.conv-filter-select {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-secondary);
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.conv-filter-select:focus {
|
||||
border-color: var(--mc-primary);
|
||||
}
|
||||
|
||||
/* Multi-select mode. */
|
||||
.panel-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.panel-icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--mc-border);
|
||||
background: var(--mc-panel-raised);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mc-text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.panel-icon-btn:hover {
|
||||
color: var(--mc-text-primary);
|
||||
border-color: var(--mc-text-tertiary);
|
||||
}
|
||||
|
||||
.panel-icon-btn.active {
|
||||
background: var(--mc-primary);
|
||||
border-color: var(--mc-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.conv-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conv-checkbox input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--mc-primary);
|
||||
}
|
||||
|
||||
.conv-item.is-selected {
|
||||
background: var(--mc-primary-bg);
|
||||
}
|
||||
|
||||
.conv-select-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--mc-border-light);
|
||||
}
|
||||
|
||||
.conv-select-all {
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-select-all:hover {
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
|
||||
.conv-select-count {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-batch-delete {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: var(--mc-danger);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.conv-batch-delete:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.conv-batch-delete:not(:disabled):hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.empty-convs {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user