refactor(chat): extract conversation sidebar and shared UI primitives

This commit is contained in:
matevip 2026-05-17 11:49:44 +08:00
parent 146be51442
commit 001cb1a2ed
11 changed files with 1362 additions and 1104 deletions

View File

@ -0,0 +1,926 @@
<template>
<div
class="conversation-panel"
:class="{ 'mobile-open': mobileOpen, 'conv-collapsed': collapsed && !isMobile }"
>
<div class="panel-header">
<div v-if="!collapsed || isMobile" class="panel-header-copy">
<div class="panel-kicker">{{ t('nav.chat') }}</div>
<h2 class="panel-title">{{ t('chat.conversations') }}</h2>
</div>
<div class="panel-header-actions">
<button
v-if="(!collapsed || 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="emit('new-chat')" :title="`${t('chat.newChat')} (⌘N)`">
<el-icon><Plus /></el-icon>
</button>
</div>
</div>
<!-- Collapse toggle -->
<button
v-if="!isMobile"
class="conv-collapse-btn"
@click="emit('toggle-collapse')"
:title="collapsed ? t('common.expandSidebar') : t('common.collapseSidebar')"
>
<svg v-if="!collapsed" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
<div class="agent-selector">
<AgentPickerDialog
block
:model-value="selectedAgentId"
:agents="agents"
:compact="collapsed && !isMobile"
:placeholder="t('chat.selectAgent')"
@change="onAgentChange"
/>
</div>
<div
v-if="(!collapsed || 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="!collapsed || isMobile" class="conv-group-title">{{ group.label }}</div>
<div
v-for="conv in group.items"
:key="conv.conversationId"
class="conv-item"
:class="{
active: !selectMode && currentConversationId === conv.conversationId,
'is-running': conv.streamStatus === 'running',
'is-selected': selectMode && selectedConvIds.includes(conv.conversationId),
'menu-open': menuConvId === conv.conversationId,
}"
@click="onConvClick(conv)"
>
<label
v-if="selectMode && (!collapsed || 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
v-if="conv.streamStatus === 'running'"
class="conv-running-dot"
:title="t('chat.streamGenerating')"
></span>
</div>
<div v-if="!collapsed || isMobile" class="conv-info">
<input
v-if="renamingConvId === conv.conversationId"
v-model="renameText"
class="conv-title-input"
@keydown.enter="confirmRename(conv)"
@keydown.escape="cancelRename"
@blur="confirmRename(conv)"
@click.stop
/>
<div v-else class="conv-title" @dblclick.stop="startRename(conv)">
<span>{{ conv.title }}</span>
<span
v-if="hasUnread(conv)"
class="conv-unread-dot"
:title="t('chat.hasUnread', '有新内容')"
></span>
<span
v-if="conv.streamStatus === 'running'"
class="conv-running-badge"
:title="t('chat.streamGenerating')"
>
<span class="conv-running-badge-pulse"></span>
{{ t('chat.streamGenerating') }}
</span>
</div>
<div class="conv-meta">
<span>{{ t('chat.messages', { count: conv.messageCount }) }}</span>
<span class="conv-dot">·</span>
<span>{{ formatConversationTime(conv.lastActiveTime) }}</span>
</div>
</div>
<!-- Single overflow ("⋮") button opens the conversation context menu. -->
<div v-if="!selectMode && (!collapsed || isMobile)" class="conv-kebab-wrap">
<button
class="conv-kebab"
:class="{ open: menuConvId === conv.conversationId }"
@click.stop="openMenu(conv, $event)"
:title="t('common.more')"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="12" cy="19" r="1.6"/></svg>
</button>
</div>
</div>
</template>
<div v-if="conversations.length === 0" class="empty-convs">
<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 && (!collapsed || 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>
<!-- Conversation context menu (kebab). -->
<DropdownMenu
:open="!!menuConv"
:anchor="menuAnchor"
:items="menuItems"
@select="onMenuSelect"
@close="closeMenu"
>
<template #item-icon="{ item }">
<svg v-if="item.key === 'pin'" width="15" height="15" viewBox="0 0 24 24" fill="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>
<svg v-else-if="item.key === 'rename'" width="15" height="15" 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>
<svg v-else-if="item.key === 'delete'" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</template>
</DropdownMenu>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { Plus } from '@element-plus/icons-vue'
import AgentPickerDialog from '@/components/common/AgentPickerDialog.vue'
import DropdownMenu, { type DropdownMenuItem } from '@/components/common/DropdownMenu.vue'
import { conversationApi } from '@/api/index'
import { channelIconUrl } from '@/utils/channelSource'
import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm'
import type { Conversation, Agent } from '@/types'
const props = defineProps<{
conversations: Conversation[]
currentConversationId: string
agents: Agent[]
selectedAgentId: string | number
collapsed: boolean
mobileOpen: boolean
isMobile: boolean
}>()
const emit = defineEmits<{
(e: 'select', conv: Conversation): void
(e: 'new-chat'): void
(e: 'agent-picked', value: string | number | null): void
(e: 'toggle-collapse'): void
(e: 'refresh'): void
(e: 'deleted', ids: string[]): void
}>()
const { t } = useI18n()
function onAgentChange(value: string | number | null) {
emit('agent-picked', value)
}
// ==================== Agent filter ====================
// Narrow the list down to a single agent's conversations.
const convAgentFilter = ref('')
// Distinct agents present in the conversation list drives the filter
// dropdown. Hidden when fewer than two agents have conversations.
const agentFilterOptions = computed(() => {
const seen = new Map<string, string>()
for (const conv of props.conversations) {
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 }))
})
// ==================== Grouping ====================
const groupedConversations = computed(() => {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const yesterdayStart = todayStart - 86400000
const last7Start = todayStart - 7 * 86400000
// 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 },
{ label: t('chat.dateToday'), items: [] },
{ label: t('chat.dateYesterday'), items: [] },
{ label: t('chat.dateLast7Days'), items: [] },
{ label: t('chat.dateEarlier'), items: [] },
]
const agentFilter = convAgentFilter.value
for (const conv of props.conversations) {
if (agentFilter && String(conv.agentId ?? '') !== agentFilter) continue
if ((conv.conversationId && conv.conversationId.startsWith('tasks_')) || conv.pinned) {
pinned.push(conv)
continue
}
const ts = conv.lastActiveTime ? new Date(conv.lastActiveTime).getTime() : 0
if (ts >= todayStart) groups[1].items.push(conv)
else if (ts >= yesterdayStart) groups[2].items.push(conv)
else if (ts >= last7Start) groups[3].items.push(conv)
else groups[4].items.push(conv)
}
return groups.filter(g => g.items.length > 0)
})
// ==================== Multi-select ====================
const selectMode = ref(false)
const selectedConvIds = ref<string[]>([])
function toggleSelectMode() {
selectMode.value = !selectMode.value
selectedConvIds.value = []
closeMenu()
}
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 emit('select', conv)
}
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]
}
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)
emit('deleted', ids)
selectedConvIds.value = []
selectMode.value = false
} catch {
mcToast.error(t('chat.batchDeleteFailed'))
}
}
// ==================== Rename ====================
const renamingConvId = ref('')
const renameText = ref('')
function startRename(conv: Conversation) {
renamingConvId.value = conv.conversationId
renameText.value = conv.title || ''
// Only one rename input exists at a time (v-if), so a query is reliable
// a template ref inside v-for resolves to an array and would not focus.
nextTick(() => {
const el = document.querySelector<HTMLInputElement>('.conv-title-input')
el?.focus()
el?.select()
})
}
async function confirmRename(conv: Conversation) {
const newTitle = renameText.value.trim()
renamingConvId.value = ''
if (!newTitle || newTitle === conv.title) return
conv.title = newTitle
try {
await conversationApi.rename(conv.conversationId, newTitle)
} catch {
// Revert by asking the parent to reload the authoritative list.
emit('refresh')
}
}
function cancelRename() {
renamingConvId.value = ''
}
// ==================== Pin ====================
async function togglePin(conv: Conversation) {
const next = !conv.pinned
conv.pinned = next ? 1 : 0
try {
await conversationApi.setPinned(conv.conversationId, next)
emit('refresh')
} catch {
conv.pinned = next ? 0 : 1
mcToast.error(t('chat.pinFailed'))
}
}
// ==================== Delete ====================
async function confirmDelete(conv: Conversation) {
const ok = await mcConfirm({
title: t('common.confirm'),
message: t('chat.deleteConfirm') || 'Delete this conversation?',
tone: 'danger',
})
if (!ok) return
try {
await conversationApi.delete(conv.conversationId)
emit('deleted', [conv.conversationId])
} catch {
mcToast.error(t('chat.deleteConversationFailed'))
}
}
// ==================== Unread / formatting ====================
// Written by ChatConsole's markConversationViewed when a conversation is opened.
const VIEWED_KEY_PREFIX = 'mc-conv-viewed:'
function hasUnread(conv: Conversation): boolean {
// Only the unified tasks_<wsId> conversation gets the unread treatment.
if (!conv.conversationId || !conv.conversationId.startsWith('tasks_')) return false
if (!conv.lastActiveTime) return false
const lastActive = new Date(conv.lastActiveTime).getTime()
if (!Number.isFinite(lastActive)) return false
let viewed = 0
try {
viewed = Number(localStorage.getItem(VIEWED_KEY_PREFIX + conv.conversationId) || '0')
} catch {
// Treat as never-viewed when storage is unavailable.
}
if (props.currentConversationId === conv.conversationId) return false
return lastActive > viewed
}
function formatConversationTime(time?: string) {
if (!time) return t('chat.timeJustNow')
const date = new Date(time)
const diff = Date.now() - date.getTime()
if (diff < 60 * 60 * 1000) return t('chat.timeMinutesAgo', { n: Math.max(1, Math.floor(diff / (60 * 1000))) })
if (diff < 24 * 60 * 60 * 1000) return t('chat.timeHoursAgo', { n: Math.floor(diff / (60 * 60 * 1000)) })
return date.toLocaleDateString()
}
// ==================== Context menu ====================
// One shared DropdownMenu instance, anchored to whichever row's kebab was
// clicked. menuConv being non-null is the open state.
const menuConv = ref<Conversation | null>(null)
const menuConvId = computed(() => menuConv.value?.conversationId || '')
const menuAnchor = ref<HTMLElement | null>(null)
const menuItems = computed<DropdownMenuItem[]>(() => [
{ key: 'pin', label: menuConv.value?.pinned ? t('chat.unpin') : t('chat.pin') },
{ key: 'rename', label: t('chat.rename') },
{ divider: true },
{ key: 'delete', label: t('common.delete'), danger: true },
])
function openMenu(conv: Conversation, e: MouseEvent) {
if (menuConv.value?.conversationId === conv.conversationId) {
closeMenu()
return
}
menuAnchor.value = e.currentTarget as HTMLElement
menuConv.value = conv
}
function closeMenu() {
menuConv.value = null
}
function onMenuSelect(item: DropdownMenuItem) {
const conv = menuConv.value
if (!conv) return
if (item.key === 'pin') togglePin(conv)
else if (item.key === 'rename') startRename(conv)
else if (item.key === 'delete') confirmDelete(conv)
}
</script>
<style scoped>
.conversation-panel {
width: 248px;
min-width: 248px;
background: linear-gradient(180deg, var(--mc-panel-top), var(--mc-panel-bottom));
border-right: 1px solid var(--mc-border-light);
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 0.25s ease, min-width 0.25s ease;
}
.conversation-panel.conv-collapsed {
width: 54px;
min-width: 54px;
}
.conversation-panel.conv-collapsed .panel-header {
justify-content: center;
padding: 14px 8px 12px;
}
.conversation-panel.conv-collapsed .agent-selector {
padding: 10px 6px 12px;
}
.conversation-panel.conv-collapsed .conv-item {
justify-content: center;
padding: 10px 6px;
}
.conversation-panel.conv-collapsed .conv-icon {
margin: 0;
}
.conv-collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 28px;
border: none;
border-bottom: 1px solid var(--mc-border-light);
background: transparent;
color: var(--mc-text-tertiary);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.conv-collapse-btn:hover {
background: var(--mc-bg-muted);
color: var(--mc-text-primary);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px 12px;
border-bottom: 1px solid var(--mc-border-light);
}
.panel-header-copy {
min-width: 0;
}
.panel-kicker {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--mc-accent);
margin-bottom: 4px;
}
.panel-title {
font-size: 16px;
font-weight: 700;
color: var(--mc-text-primary);
margin: 0;
letter-spacing: -0.03em;
}
.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;
}
.new-chat-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-primary);
transition: all 0.15s;
}
.new-chat-btn:hover {
background: var(--mc-primary);
border-color: var(--mc-primary);
color: white;
}
.agent-selector {
padding: 10px 12px 12px;
border-bottom: 1px solid var(--mc-border-light);
position: relative;
}
/* 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);
}
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.conv-group-title {
padding: 10px 10px 6px;
font-size: 10px;
font-weight: 700;
color: var(--mc-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.conv-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 11px;
border-radius: 14px;
cursor: pointer;
transition: all 0.15s;
}
.conv-item:hover {
background: var(--mc-bg-sunken);
transform: translateY(-1px);
}
.conv-item.active {
background: var(--mc-primary-bg);
}
.conv-icon {
color: var(--mc-text-tertiary);
flex-shrink: 0;
position: relative;
}
.conv-item.active .conv-icon {
color: var(--mc-primary);
}
/* Running indicator: pulsing dot on the icon corner (visible collapsed too). */
.conv-running-dot {
position: absolute;
top: -2px;
right: -2px;
width: 7px;
height: 7px;
border-radius: 50%;
background: #fbbf24;
box-shadow: 0 0 4px rgba(251, 191, 36, 0.6), 0 0 0 2px var(--mc-bg-primary, #fff);
animation: pulse-dot 1.2s infinite;
pointer-events: none;
}
/* Inline unread accent dot shown next to title when a conversation has
activity since the user's last view (currently scoped to tasks_<wsId>). */
.conv-unread-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mc-primary, #d97757);
margin-left: 6px;
flex-shrink: 0;
vertical-align: middle;
}
.conv-item.is-running {
background: color-mix(in srgb, #fbbf24 8%, transparent);
}
.conv-item.is-running:hover {
background: color-mix(in srgb, #fbbf24 14%, var(--mc-bg-sunken));
}
.conv-item.is-running.active {
background: var(--mc-primary-bg);
}
/* Expanded state: small "generating..." badge to the right of the title. */
.conv-running-badge {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
font-size: 10px;
font-weight: 500;
color: #b45309;
background: rgba(251, 191, 36, 0.15);
border: 1px solid rgba(251, 191, 36, 0.3);
padding: 1px 6px 1px 5px;
border-radius: 10px;
line-height: 1.3;
white-space: nowrap;
}
.conv-running-badge-pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: #f59e0b;
animation: pulse-dot 1.2s infinite;
}
.conv-info {
flex: 1;
overflow: hidden;
}
.conv-title {
font-size: 13px;
font-weight: 500;
color: var(--mc-text-primary);
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
/* The title text itself carries the ellipsis; overflow:hidden on the flex
parent would otherwise stop text-overflow from working. */
.conv-title > span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
}
.conv-item.active .conv-title {
color: var(--mc-primary);
}
.conv-meta {
font-size: 11px;
color: var(--mc-text-tertiary);
margin-top: 1px;
display: flex;
align-items: center;
gap: 4px;
}
.conv-dot {
color: var(--mc-text-tertiary);
}
.conv-title-input {
width: 100%;
font-size: 13px;
font-weight: 500;
color: var(--mc-text-primary);
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-primary);
border-radius: 6px;
padding: 2px 6px;
outline: none;
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.15);
}
/* The kebab overlays the right edge of the row so it reserves no layout
width the conversation title keeps its full space. A left-fading
gradient masks the meta text behind it when the row is hovered. */
.conv-kebab-wrap {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
padding-left: 18px;
border-radius: 12px;
background: linear-gradient(to right, transparent, var(--mc-bg-sunken) 42%);
opacity: 0;
transition: opacity 0.15s;
}
.conv-item:hover .conv-kebab-wrap,
.conv-item.active .conv-kebab-wrap,
.conv-item.menu-open .conv-kebab-wrap {
opacity: 1;
}
.conv-item.active .conv-kebab-wrap {
background: linear-gradient(to right, transparent, var(--mc-primary-bg) 42%);
}
.conv-kebab {
width: 24px;
height: 24px;
border: none;
background: none;
cursor: pointer;
color: var(--mc-text-tertiary);
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
padding: 0;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.conv-kebab:hover,
.conv-kebab.open {
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
}
.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;
color: var(--mc-text-tertiary);
font-size: 13px;
line-height: 1.8;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ===== Mobile ===== */
@media (max-width: 768px) {
.conversation-panel {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
width: 272px;
min-width: 272px;
transform: translateX(-100%);
transition: transform 0.25s ease;
box-shadow: none;
}
.conversation-panel.mobile-open {
transform: translateX(0);
box-shadow: 4px 0 16px rgba(0, 0, 0, 0.1);
}
}
</style>

View File

@ -0,0 +1,224 @@
<script lang="ts">
export interface DropdownMenuItem {
/** Identifier emitted via @select. Omit for dividers. */
key?: string
label?: string
/** Render with the danger (destructive) color. */
danger?: boolean
/** Render a thin separator line instead of a clickable row. */
divider?: boolean
/** Disable the row. */
disabled?: boolean
}
</script>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
const props = withDefaults(defineProps<{
/** Whether the menu is shown. */
open: boolean
/** Trigger element the menu anchors to. */
anchor: HTMLElement | null
items: DropdownMenuItem[]
/** Horizontal alignment of the menu against the anchor. */
align?: 'left' | 'right'
/** Menu width in px. */
width?: number
}>(), {
align: 'right',
width: 188,
})
const emit = defineEmits<{
(e: 'select', item: DropdownMenuItem): void
(e: 'close'): void
}>()
const menuStyle = ref<Record<string, string>>({})
/** Position the menu against the anchor, flipping above when it would overflow. */
function reposition() {
const anchor = props.anchor
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const rows = props.items.filter(i => !i.divider).length
const dividers = props.items.filter(i => i.divider).length
const estHeight = rows * 38 + dividers * 5 + 8
let left = props.align === 'right' ? rect.right - props.width : rect.left
left = Math.max(8, Math.min(left, window.innerWidth - props.width - 8))
let top = rect.bottom + 4
if (top + estHeight > window.innerHeight - 8) top = rect.top - estHeight - 4
top = Math.max(8, top)
menuStyle.value = { top: `${top}px`, left: `${left}px`, width: `${props.width}px` }
}
function onSelect(item: DropdownMenuItem) {
if (item.disabled) return
emit('select', item)
emit('close')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
// A scroll or resize while open detaches the menu from its anchor close it.
function onViewportChange() {
emit('close')
}
watch(() => props.open, (isOpen) => {
if (isOpen) {
reposition()
window.addEventListener('keydown', onKeydown)
window.addEventListener('scroll', onViewportChange, true)
window.addEventListener('resize', onViewportChange)
} else {
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('scroll', onViewportChange, true)
window.removeEventListener('resize', onViewportChange)
}
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('scroll', onViewportChange, true)
window.removeEventListener('resize', onViewportChange)
})
</script>
<template>
<Teleport to="body">
<Transition name="dropdown-fade">
<div
v-if="open"
class="dropdown-backdrop"
@click="emit('close')"
@contextmenu.prevent="emit('close')"
></div>
</Transition>
<Transition name="dropdown-pop">
<div v-if="open" class="dropdown-menu" :style="menuStyle">
<template v-for="(item, i) in items" :key="item.key ?? `_d${i}`">
<div v-if="item.divider" class="dropdown-divider"></div>
<button
v-else
class="dropdown-item"
:class="{ 'is-danger': item.danger, 'is-disabled': item.disabled }"
:disabled="item.disabled"
@click="onSelect(item)"
>
<span v-if="$slots['item-icon']" class="dropdown-item-icon">
<slot name="item-icon" :item="item" />
</span>
<span class="dropdown-item-label">{{ item.label }}</span>
</button>
</template>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.dropdown-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
}
.dropdown-menu {
position: fixed;
z-index: 1001;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border);
border-radius: 12px;
padding: 4px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14);
}
.dropdown-item {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 9px 11px;
border: none;
background: none;
border-radius: 8px;
font-size: 13px;
color: var(--mc-text-primary);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.dropdown-item:hover {
background: var(--mc-bg-sunken);
}
.dropdown-item.is-danger {
color: var(--mc-danger);
}
.dropdown-item.is-danger:hover {
background: var(--mc-danger-bg);
}
.dropdown-item.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dropdown-item.is-disabled:hover {
background: none;
}
.dropdown-item-icon {
display: flex;
align-items: center;
flex-shrink: 0;
color: var(--mc-text-tertiary);
}
.dropdown-item.is-danger .dropdown-item-icon {
color: var(--mc-danger);
}
.dropdown-item-label {
flex: 1;
text-align: left;
}
.dropdown-divider {
height: 1px;
background: var(--mc-border-light);
margin: 2px 8px;
}
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
transition: opacity 0.12s ease;
}
.dropdown-fade-enter-from,
.dropdown-fade-leave-to {
opacity: 0;
}
.dropdown-pop-enter-active {
transition: all 0.13s ease-out;
}
.dropdown-pop-leave-active {
transition: all 0.1s ease-in;
}
.dropdown-pop-enter-from {
opacity: 0;
transform: translateY(-6px) scale(0.97);
}
.dropdown-pop-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
</style>

View File

@ -91,10 +91,11 @@
<!-- ZIP 上传 -->
<div v-if="activeTab === 'zip'" class="tab-content">
<div class="upload-zone"
:class="{ 'drag-over': dragOver, 'has-file': zipFile }"
@dragover.prevent="dragOver = true"
@dragleave="dragOver = false"
@drop.prevent="handleDrop"
:class="{ 'drag-over': isDragging, 'has-file': zipFile }"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
@click="triggerFileInput">
<input ref="zipInputRef" type="file" accept=".zip" class="hidden-input" @change="handleFileSelect" />
<svg v-if="!zipFile" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity: 0.4; margin-bottom: 8px;">
@ -150,6 +151,7 @@
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { useFileDrop } from '@/composables/useFileDrop'
import { skillInstallApi } from '@/api/index'
import type { InstallTask, HubSkillInfo } from '@/types/index'
@ -177,7 +179,7 @@ const overwriteExisting = ref(false)
const currentTask = ref<InstallTask | null>(null)
const zipFile = ref<File | null>(null)
const zipInputRef = ref<HTMLInputElement | null>(null)
const dragOver = ref(false)
const { isDragging, onDragEnter, onDragLeave, onDrop } = useFileDrop(handleDroppedZip)
let pollTimer: ReturnType<typeof setInterval> | null = null
watch(() => props.visible, (val) => {
@ -277,8 +279,7 @@ function handleFileSelect(e: Event) {
input.value = ''
}
function handleDrop(e: DragEvent) {
dragOver.value = false
function handleDroppedZip(e: DragEvent) {
const file = e.dataTransfer?.files?.[0]
if (file && file.name.endsWith('.zip')) {
zipFile.value = file

View File

@ -0,0 +1,40 @@
import { ref, onMounted, onBeforeUnmount, type Ref } from 'vue'
/**
* Reactive CSS media-query match.
*
* The query is evaluated synchronously when the composable is called, so the
* very first render already reflects the real viewport (no mount-time flash
* from a `false` default). The `change` listener is attached on mount and
* removed on unmount.
*
* @param query a CSS media query, e.g. `(max-width: 768px)`.
* @returns a readonly-by-convention ref that stays in sync with the query.
*/
export function useMediaQuery(query: string): Ref<boolean> {
const mql = typeof window !== 'undefined' && window.matchMedia
? window.matchMedia(query)
: null
const matches = ref(mql ? mql.matches : false)
function onChange(e: MediaQueryListEvent) {
matches.value = e.matches
}
onMounted(() => mql?.addEventListener('change', onChange))
onBeforeUnmount(() => mql?.removeEventListener('change', onChange))
return matches
}
/** App-wide breakpoints — keep in sync with the CSS `@media` rules. */
export const BREAKPOINTS = {
mobile: '(max-width: 768px)',
/** Narrow desktop — e.g. where the chat sidebar auto-collapses. */
compact: '(max-width: 1200px)',
} as const
/** True on viewports at or below the mobile breakpoint (≤ 768px). */
export function useIsMobile(): Ref<boolean> {
return useMediaQuery(BREAKPOINTS.mobile)
}

View File

@ -0,0 +1,56 @@
import { ref } from 'vue'
/**
* Drag-and-drop file upload helper.
*
* Tracks the hover state of a drop zone and hands the drop event to the
* caller. The tricky part it encapsulates is the nested-element flicker:
* `dragenter` / `dragleave` fire for every child the pointer crosses, so a
* naive boolean flag flickers off mid-drag. A depth counter fixes that
* `isDragging` only clears once every entered element has been left.
*
* Wire the handlers onto the drop zone and bind `isDragging` for the visual
* overlay:
*
* ```vue
* <div
* @dragenter.prevent="onDragEnter"
* @dragover.prevent
* @dragleave.prevent="onDragLeave"
* @drop.prevent="onDrop"
* :class="{ 'is-dragging': isDragging }"
* />
* ```
*
* The drop payload is intentionally not pre-parsed callers extract whatever
* they need from the {@link DragEvent} (`dataTransfer.files`, `.items` for
* directory entries, etc.).
*
* @param onDrop called with the raw drop event after the drag state is reset.
*/
export function useFileDrop(onDrop: (e: DragEvent) => void) {
const isDragging = ref(false)
let dragCounter = 0
function onDragEnter(e: DragEvent) {
dragCounter++
// Only react to actual file drags — ignore text or in-page element drags.
if (e.dataTransfer?.types?.includes('Files')) isDragging.value = true
}
function onDragLeave() {
dragCounter--
if (dragCounter <= 0) {
dragCounter = 0
isDragging.value = false
}
}
function handleDrop(e: DragEvent) {
dragCounter = 0
isDragging.value = false
onDrop(e)
}
return { isDragging, onDragEnter, onDragLeave, onDrop: handleDrop }
}

File diff suppressed because it is too large Load Diff

View File

@ -201,8 +201,9 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useIsMobile } from '@/composables/useBreakpoint'
import { activityApi } from '@/api'
import McPagination from '@/components/common/McPagination.vue'
@ -230,11 +231,7 @@ interface ActivityEvent {
* drawer to full-width sheet, and to drop the size-switcher /
* jumper when there's no horizontal room for them.
*/
const isMobile = ref(false)
let mq: MediaQueryList | null = null
function syncMobile(e: MediaQueryListEvent | MediaQueryList) {
isMobile.value = e.matches
}
const isMobile = useIsMobile()
const drawerSize = computed(() => isMobile.value ? '100%' : '720px')
@ -462,15 +459,8 @@ function absoluteTime(event: ActivityEvent): string {
}
onMounted(() => {
mq = window.matchMedia('(max-width: 768px)')
syncMobile(mq)
mq.addEventListener('change', syncMobile)
loadEvents()
})
onBeforeUnmount(() => {
mq?.removeEventListener('change', syncMobile)
})
</script>
<style scoped>

View File

@ -41,9 +41,10 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useMediaQuery, BREAKPOINTS } from '@/composables/useBreakpoint'
const route = useRoute()
const { t } = useI18n()
@ -51,7 +52,6 @@ const { t } = useI18n()
// Settings key
const navCollapsed = ref(localStorage.getItem('mc-settings-nav-collapsed') === 'true')
const userExplicit = ref(localStorage.getItem('mc-settings-nav-collapsed') === 'true')
let mediumQuery: MediaQueryList | null = null
function toggleNav() {
navCollapsed.value = !navCollapsed.value
@ -59,23 +59,11 @@ function toggleNav() {
localStorage.setItem('mc-settings-nav-collapsed', String(navCollapsed.value))
}
function handleMediumChange(e: MediaQueryListEvent | MediaQueryList) {
if (e.matches && !userExplicit.value) {
navCollapsed.value = true
} else if (!e.matches && !userExplicit.value) {
navCollapsed.value = false
}
}
onMounted(() => {
mediumQuery = window.matchMedia('(max-width: 1200px)')
handleMediumChange(mediumQuery)
mediumQuery.addEventListener('change', handleMediumChange)
})
onBeforeUnmount(() => {
mediumQuery?.removeEventListener('change', handleMediumChange)
})
// Auto-collapse the nav on narrow desktop unless the user collapsed it explicitly.
const compactViewport = useMediaQuery(BREAKPOINTS.compact)
watch(compactViewport, (compact) => {
if (!userExplicit.value) navCollapsed.value = compact
}, { immediate: true })
const sections = computed(() => [
{

View File

@ -43,8 +43,9 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useMediaQuery, BREAKPOINTS } from '@/composables/useBreakpoint'
import { useI18n } from 'vue-i18n'
const route = useRoute()
@ -56,7 +57,7 @@ const COMPACT_ROUTES = ['/settings/workflows', '/settings/triggers']
const navCollapsed = ref(localStorage.getItem('mc-settings-nav-collapsed') === 'true')
const userExplicit = ref(localStorage.getItem('mc-settings-nav-collapsed') !== null)
let mediumQuery: MediaQueryList | null = null
const compactViewport = useMediaQuery(BREAKPOINTS.compact)
function toggleNav() {
navCollapsed.value = !navCollapsed.value
@ -70,25 +71,11 @@ function isCompactRoute(path: string): boolean {
function recomputeAuto() {
if (userExplicit.value) return
const compact = isCompactRoute(route.path) || !!mediumQuery?.matches
navCollapsed.value = compact
}
function handleMediumChange(_e: MediaQueryListEvent | MediaQueryList) {
recomputeAuto()
navCollapsed.value = isCompactRoute(route.path) || compactViewport.value
}
watch(() => route.path, recomputeAuto)
onMounted(() => {
mediumQuery = window.matchMedia('(max-width: 1200px)')
recomputeAuto()
mediumQuery.addEventListener('change', handleMediumChange)
})
onBeforeUnmount(() => {
mediumQuery?.removeEventListener('change', handleMediumChange)
})
watch(compactViewport, recomputeAuto, { immediate: true })
const sections = computed(() => [
{

View File

@ -287,6 +287,7 @@
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { mcToast } from '@/composables/useMcToast'
import { useFileDrop } from '@/composables/useFileDrop'
import { Download } from '@element-plus/icons-vue'
import { useWikiStore } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
@ -493,21 +494,12 @@ const scanning = ref(false)
const scanResult = ref<{ scanned: number; added: number; skipped: number } | null>(null)
// Drag-over state
// Use a counter to handle nested dragenter/dragleave without flickering.
const isDragging = ref(false)
let dragCounter = 0
const { isDragging, onDragEnter, onDragLeave, onDrop: handleDrop } = useFileDrop(uploadDroppedFiles)
function onDragEnter() {
dragCounter++
isDragging.value = true
}
function onDragLeave() {
dragCounter--
if (dragCounter <= 0) {
dragCounter = 0
isDragging.value = false
}
async function uploadDroppedFiles(event: DragEvent) {
if (!event.dataTransfer?.files || !store.currentKB) return
const kbId = store.currentKB.id
await Promise.all(Array.from(event.dataTransfer.files).map(f => uploadFile(kbId, f)))
}
// Optimistic upload items
@ -567,14 +559,6 @@ async function handleFileSelect(event: Event) {
input.value = ''
}
async function handleDrop(event: DragEvent) {
// Reset drag state
dragCounter = 0
isDragging.value = false
if (!event.dataTransfer?.files || !store.currentKB) return
const kbId = store.currentKB.id
await Promise.all(Array.from(event.dataTransfer.files).map(f => uploadFile(kbId, f)))
}
async function handleAddText() {
if (!store.currentKB) return

View File

@ -186,6 +186,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useIsMobile, useMediaQuery } from '@/composables/useBreakpoint'
import { useI18n } from 'vue-i18n'
import { useThemeStore } from '@/stores/useThemeStore'
import { version as appVersion } from '../../../package.json'
@ -237,26 +238,24 @@ const { stuckAgents, pendingApprovals } = useNotificationCenter()
const liveAlertActive = computed(() => isAdminRole.value && stuckAgents.value > 0)
//
const isMobile = ref(false)
const mobileMenuOpen = ref(false)
let mobileQuery: MediaQueryList | null = null
// 1024px
let mediumQuery: MediaQueryList | null = null
const userExplicitCollapse = ref(localStorage.getItem('mc-sidebar-collapsed') === 'true')
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
isMobile.value = e.matches
if (!e.matches) mobileMenuOpen.value = false
if (e.matches) footerPanelOpen.value = false
}
const isMobile = useIsMobile()
// 1024px
const compactViewport = useMediaQuery('(max-width: 1024px)')
function handleMediumChange(e: MediaQueryListEvent | MediaQueryList) {
if (e.matches && !userExplicitCollapse.value) {
sidebarCollapsed.value = true
} else if (!e.matches && !userExplicitCollapse.value) {
sidebarCollapsed.value = false
}
}
// Mobile breakpoint side effects: close the drawer / footer panel when the
// layout flips between mobile and desktop.
watch(isMobile, (mobile) => {
if (!mobile) mobileMenuOpen.value = false
if (mobile) footerPanelOpen.value = false
})
// Auto-collapse the sidebar on narrow desktop unless the user set it explicitly.
watch(compactViewport, (compact) => {
if (!userExplicitCollapse.value) sidebarCollapsed.value = compact
}, { immediate: true })
const shortcutsHintText = computed(() =>
`Ctrl+K ${t('nav.shortcutAgents')} | Ctrl+N ${t('nav.shortcutNew')}`,
@ -301,14 +300,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
}
onMounted(async () => {
mobileQuery = window.matchMedia('(max-width: 768px)')
handleMobileChange(mobileQuery)
mobileQuery.addEventListener('change', handleMobileChange)
mediumQuery = window.matchMedia('(max-width: 1024px)')
handleMediumChange(mediumQuery)
mediumQuery.addEventListener('change', handleMediumChange)
window.addEventListener('keydown', onGlobalKeydown)
// Check onboarding status
@ -330,8 +321,6 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
mobileQuery?.removeEventListener('change', handleMobileChange)
mediumQuery?.removeEventListener('change', handleMediumChange)
window.removeEventListener('keydown', onGlobalKeydown)
})