mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +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 上传 -->
|
<!-- ZIP 上传 -->
|
||||||
<div v-if="activeTab === 'zip'" class="tab-content">
|
<div v-if="activeTab === 'zip'" class="tab-content">
|
||||||
<div class="upload-zone"
|
<div class="upload-zone"
|
||||||
:class="{ 'drag-over': dragOver, 'has-file': zipFile }"
|
:class="{ 'drag-over': isDragging, 'has-file': zipFile }"
|
||||||
@dragover.prevent="dragOver = true"
|
@dragenter.prevent="onDragEnter"
|
||||||
@dragleave="dragOver = false"
|
@dragover.prevent
|
||||||
@drop.prevent="handleDrop"
|
@dragleave.prevent="onDragLeave"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
@click="triggerFileInput">
|
@click="triggerFileInput">
|
||||||
<input ref="zipInputRef" type="file" accept=".zip" class="hidden-input" @change="handleFileSelect" />
|
<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;">
|
<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 { ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mcToast } from '@/composables/useMcToast'
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
|
import { useFileDrop } from '@/composables/useFileDrop'
|
||||||
import { skillInstallApi } from '@/api/index'
|
import { skillInstallApi } from '@/api/index'
|
||||||
import type { InstallTask, HubSkillInfo } from '@/types/index'
|
import type { InstallTask, HubSkillInfo } from '@/types/index'
|
||||||
|
|
||||||
@ -177,7 +179,7 @@ const overwriteExisting = ref(false)
|
|||||||
const currentTask = ref<InstallTask | null>(null)
|
const currentTask = ref<InstallTask | null>(null)
|
||||||
const zipFile = ref<File | null>(null)
|
const zipFile = ref<File | null>(null)
|
||||||
const zipInputRef = ref<HTMLInputElement | 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
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
watch(() => props.visible, (val) => {
|
||||||
@ -277,8 +279,7 @@ function handleFileSelect(e: Event) {
|
|||||||
input.value = ''
|
input.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
function handleDroppedZip(e: DragEvent) {
|
||||||
dragOver.value = false
|
|
||||||
const file = e.dataTransfer?.files?.[0]
|
const file = e.dataTransfer?.files?.[0]
|
||||||
if (file && file.name.endsWith('.zip')) {
|
if (file && file.name.endsWith('.zip')) {
|
||||||
zipFile.value = file
|
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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
|
import { useIsMobile } from '@/composables/useBreakpoint'
|
||||||
import { activityApi } from '@/api'
|
import { activityApi } from '@/api'
|
||||||
import McPagination from '@/components/common/McPagination.vue'
|
import McPagination from '@/components/common/McPagination.vue'
|
||||||
|
|
||||||
@ -230,11 +231,7 @@ interface ActivityEvent {
|
|||||||
* drawer to full-width sheet, and to drop the size-switcher /
|
* drawer to full-width sheet, and to drop the size-switcher /
|
||||||
* jumper when there's no horizontal room for them.
|
* jumper when there's no horizontal room for them.
|
||||||
*/
|
*/
|
||||||
const isMobile = ref(false)
|
const isMobile = useIsMobile()
|
||||||
let mq: MediaQueryList | null = null
|
|
||||||
function syncMobile(e: MediaQueryListEvent | MediaQueryList) {
|
|
||||||
isMobile.value = e.matches
|
|
||||||
}
|
|
||||||
|
|
||||||
const drawerSize = computed(() => isMobile.value ? '100%' : '720px')
|
const drawerSize = computed(() => isMobile.value ? '100%' : '720px')
|
||||||
|
|
||||||
@ -462,15 +459,8 @@ function absoluteTime(event: ActivityEvent): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
mq = window.matchMedia('(max-width: 768px)')
|
|
||||||
syncMobile(mq)
|
|
||||||
mq.addEventListener('change', syncMobile)
|
|
||||||
loadEvents()
|
loadEvents()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
mq?.removeEventListener('change', syncMobile)
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -41,9 +41,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useMediaQuery, BREAKPOINTS } from '@/composables/useBreakpoint'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -51,7 +52,6 @@ const { t } = useI18n()
|
|||||||
// 折叠状态(与 Settings 共享 key)
|
// 折叠状态(与 Settings 共享 key)
|
||||||
const navCollapsed = ref(localStorage.getItem('mc-settings-nav-collapsed') === 'true')
|
const navCollapsed = ref(localStorage.getItem('mc-settings-nav-collapsed') === 'true')
|
||||||
const userExplicit = 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() {
|
function toggleNav() {
|
||||||
navCollapsed.value = !navCollapsed.value
|
navCollapsed.value = !navCollapsed.value
|
||||||
@ -59,23 +59,11 @@ function toggleNav() {
|
|||||||
localStorage.setItem('mc-settings-nav-collapsed', String(navCollapsed.value))
|
localStorage.setItem('mc-settings-nav-collapsed', String(navCollapsed.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMediumChange(e: MediaQueryListEvent | MediaQueryList) {
|
// Auto-collapse the nav on narrow desktop unless the user collapsed it explicitly.
|
||||||
if (e.matches && !userExplicit.value) {
|
const compactViewport = useMediaQuery(BREAKPOINTS.compact)
|
||||||
navCollapsed.value = true
|
watch(compactViewport, (compact) => {
|
||||||
} else if (!e.matches && !userExplicit.value) {
|
if (!userExplicit.value) navCollapsed.value = compact
|
||||||
navCollapsed.value = false
|
}, { immediate: true })
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
mediumQuery = window.matchMedia('(max-width: 1200px)')
|
|
||||||
handleMediumChange(mediumQuery)
|
|
||||||
mediumQuery.addEventListener('change', handleMediumChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
mediumQuery?.removeEventListener('change', handleMediumChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
const sections = computed(() => [
|
const sections = computed(() => [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -43,8 +43,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useMediaQuery, BREAKPOINTS } from '@/composables/useBreakpoint'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const route = useRoute()
|
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 navCollapsed = ref(localStorage.getItem('mc-settings-nav-collapsed') === 'true')
|
||||||
const userExplicit = ref(localStorage.getItem('mc-settings-nav-collapsed') !== null)
|
const userExplicit = ref(localStorage.getItem('mc-settings-nav-collapsed') !== null)
|
||||||
let mediumQuery: MediaQueryList | null = null
|
const compactViewport = useMediaQuery(BREAKPOINTS.compact)
|
||||||
|
|
||||||
function toggleNav() {
|
function toggleNav() {
|
||||||
navCollapsed.value = !navCollapsed.value
|
navCollapsed.value = !navCollapsed.value
|
||||||
@ -70,25 +71,11 @@ function isCompactRoute(path: string): boolean {
|
|||||||
|
|
||||||
function recomputeAuto() {
|
function recomputeAuto() {
|
||||||
if (userExplicit.value) return
|
if (userExplicit.value) return
|
||||||
const compact = isCompactRoute(route.path) || !!mediumQuery?.matches
|
navCollapsed.value = isCompactRoute(route.path) || compactViewport.value
|
||||||
navCollapsed.value = compact
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMediumChange(_e: MediaQueryListEvent | MediaQueryList) {
|
|
||||||
recomputeAuto()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => route.path, recomputeAuto)
|
watch(() => route.path, recomputeAuto)
|
||||||
|
watch(compactViewport, recomputeAuto, { immediate: true })
|
||||||
onMounted(() => {
|
|
||||||
mediumQuery = window.matchMedia('(max-width: 1200px)')
|
|
||||||
recomputeAuto()
|
|
||||||
mediumQuery.addEventListener('change', handleMediumChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
mediumQuery?.removeEventListener('change', handleMediumChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
const sections = computed(() => [
|
const sections = computed(() => [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -287,6 +287,7 @@
|
|||||||
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mcToast } from '@/composables/useMcToast'
|
import { mcToast } from '@/composables/useMcToast'
|
||||||
|
import { useFileDrop } from '@/composables/useFileDrop'
|
||||||
import { Download } from '@element-plus/icons-vue'
|
import { Download } from '@element-plus/icons-vue'
|
||||||
import { useWikiStore } from '@/stores/useWikiStore'
|
import { useWikiStore } from '@/stores/useWikiStore'
|
||||||
import { wikiApi } from '@/api/index'
|
import { wikiApi } from '@/api/index'
|
||||||
@ -493,21 +494,12 @@ const scanning = ref(false)
|
|||||||
const scanResult = ref<{ scanned: number; added: number; skipped: number } | null>(null)
|
const scanResult = ref<{ scanned: number; added: number; skipped: number } | null>(null)
|
||||||
|
|
||||||
// ─── Drag-over state ──────────────────────────────────────────────────────────
|
// ─── Drag-over state ──────────────────────────────────────────────────────────
|
||||||
// Use a counter to handle nested dragenter/dragleave without flickering.
|
const { isDragging, onDragEnter, onDragLeave, onDrop: handleDrop } = useFileDrop(uploadDroppedFiles)
|
||||||
const isDragging = ref(false)
|
|
||||||
let dragCounter = 0
|
|
||||||
|
|
||||||
function onDragEnter() {
|
async function uploadDroppedFiles(event: DragEvent) {
|
||||||
dragCounter++
|
if (!event.dataTransfer?.files || !store.currentKB) return
|
||||||
isDragging.value = true
|
const kbId = store.currentKB.id
|
||||||
}
|
await Promise.all(Array.from(event.dataTransfer.files).map(f => uploadFile(kbId, f)))
|
||||||
|
|
||||||
function onDragLeave() {
|
|
||||||
dragCounter--
|
|
||||||
if (dragCounter <= 0) {
|
|
||||||
dragCounter = 0
|
|
||||||
isDragging.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Optimistic upload items ──────────────────────────────────────────────────
|
// ─── Optimistic upload items ──────────────────────────────────────────────────
|
||||||
@ -567,14 +559,6 @@ async function handleFileSelect(event: Event) {
|
|||||||
input.value = ''
|
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() {
|
async function handleAddText() {
|
||||||
if (!store.currentKB) return
|
if (!store.currentKB) return
|
||||||
|
|||||||
@ -186,6 +186,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useIsMobile, useMediaQuery } from '@/composables/useBreakpoint'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useThemeStore } from '@/stores/useThemeStore'
|
import { useThemeStore } from '@/stores/useThemeStore'
|
||||||
import { version as appVersion } from '../../../package.json'
|
import { version as appVersion } from '../../../package.json'
|
||||||
@ -237,26 +238,24 @@ const { stuckAgents, pendingApprovals } = useNotificationCenter()
|
|||||||
const liveAlertActive = computed(() => isAdminRole.value && stuckAgents.value > 0)
|
const liveAlertActive = computed(() => isAdminRole.value && stuckAgents.value > 0)
|
||||||
|
|
||||||
// 移动端状态
|
// 移动端状态
|
||||||
const isMobile = ref(false)
|
|
||||||
const mobileMenuOpen = 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')
|
const userExplicitCollapse = ref(localStorage.getItem('mc-sidebar-collapsed') === 'true')
|
||||||
|
|
||||||
function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
const isMobile = useIsMobile()
|
||||||
isMobile.value = e.matches
|
// 中等屏幕自动折叠(≤1024px)
|
||||||
if (!e.matches) mobileMenuOpen.value = false
|
const compactViewport = useMediaQuery('(max-width: 1024px)')
|
||||||
if (e.matches) footerPanelOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMediumChange(e: MediaQueryListEvent | MediaQueryList) {
|
// Mobile breakpoint side effects: close the drawer / footer panel when the
|
||||||
if (e.matches && !userExplicitCollapse.value) {
|
// layout flips between mobile and desktop.
|
||||||
sidebarCollapsed.value = true
|
watch(isMobile, (mobile) => {
|
||||||
} else if (!e.matches && !userExplicitCollapse.value) {
|
if (!mobile) mobileMenuOpen.value = false
|
||||||
sidebarCollapsed.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(() =>
|
const shortcutsHintText = computed(() =>
|
||||||
`Ctrl+K ${t('nav.shortcutAgents')} | Ctrl+N ${t('nav.shortcutNew')}`,
|
`Ctrl+K ${t('nav.shortcutAgents')} | Ctrl+N ${t('nav.shortcutNew')}`,
|
||||||
@ -301,14 +300,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
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)
|
window.addEventListener('keydown', onGlobalKeydown)
|
||||||
|
|
||||||
// Check onboarding status
|
// Check onboarding status
|
||||||
@ -330,8 +321,6 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
mobileQuery?.removeEventListener('change', handleMobileChange)
|
|
||||||
mediumQuery?.removeEventListener('change', handleMediumChange)
|
|
||||||
window.removeEventListener('keydown', onGlobalKeydown)
|
window.removeEventListener('keydown', onGlobalKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user