mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
refactor(chat): extract conversation sidebar and shared UI primitives
This commit is contained in:
parent
146be51442
commit
001cb1a2ed
926
mateclaw-ui/src/components/chat/ConversationSidebar.vue
Normal file
926
mateclaw-ui/src/components/chat/ConversationSidebar.vue
Normal 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>
|
||||
224
mateclaw-ui/src/components/common/DropdownMenu.vue
Normal file
224
mateclaw-ui/src/components/common/DropdownMenu.vue
Normal 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>
|
||||
@ -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
|
||||
|
||||
40
mateclaw-ui/src/composables/useBreakpoint.ts
Normal file
40
mateclaw-ui/src/composables/useBreakpoint.ts
Normal 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)
|
||||
}
|
||||
56
mateclaw-ui/src/composables/useFileDrop.ts
Normal file
56
mateclaw-ui/src/composables/useFileDrop.ts
Normal 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
@ -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>
|
||||
|
||||
@ -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(() => [
|
||||
{
|
||||
|
||||
@ -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(() => [
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user