重构: 拆AiChat God第一批ConversationSidebar(strategy)
- 新建 ai/ConversationSidebar.vue(457行): 侧栏+会话列表+搜索+重命名/导出/拖拽/分组(全迁sidebar内部态) - AiChat.vue 4081→3663行(-418, 172→148KB): sidebar template→<ConversationSidebar>+删迁移script(titleFlash/重命名/导出/拖拽/搜索/分组等) - 零props(store共享useAiStore单例) + 3emit(confirm-delete/confirm-new-conversation/export-error) + defineExpose focusSearch useConfirm非单例→删除确认emit交父(防ConfirmDialog错位);CSS未动(子组件无scoped沿用父级,样式下沉后续批) 主代兜底: vue-tsc 0 + grep ConversationSidebar抽离/AiChat emit绑定印证 strategy: 单批1-2文件原子, 最独立子组件优先降风险; AiChat余3663行(后续批MessageList/ChatInput/ApprovalPanel)
This commit is contained in:
@@ -1,208 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ai-panel">
|
<div class="ai-panel">
|
||||||
<!-- ═══ 对话侧边栏 ═══ -->
|
<!-- ═══ 对话侧边栏(第一批抽离至 ai/ConversationSidebar.vue,零行为变更) ═══ -->
|
||||||
<transition name="sidebar-slide">
|
<ConversationSidebar
|
||||||
<div
|
ref="convSidebarRef"
|
||||||
v-if="store.state.sidebarOpen"
|
@confirm-delete="confirmDeleteConversation"
|
||||||
class="ai-conv-sidebar"
|
@confirm-new-conversation="confirmNewConversation"
|
||||||
:style="{ '--sidebar-width': store.state.sidebarWidth + 'px' }"
|
@export-error="(msg: string) => showToast(msg, 'error')"
|
||||||
>
|
/>
|
||||||
<!-- 侧栏右边缘拖拽条(UX-2025-16):col-resize,拖动 mousemove 改 sidebarWidth,mouseup 持久化 -->
|
|
||||||
<div
|
|
||||||
class="ai-sidebar-resize-handle"
|
|
||||||
:title="t('aiChat.sidebarResizeHint')"
|
|
||||||
@mousedown.prevent="onResizeStart"
|
|
||||||
/>
|
|
||||||
<div class="ai-conv-sidebar-header">
|
|
||||||
<span class="ai-conv-sidebar-title">{{ $t('aiChat.sidebarTitle') }}</span>
|
|
||||||
<button class="ai-btn-icon" @click="confirmNewConversation" :title="$t('aiChat.newConversation')">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- UX-06 §3.1: 对话搜索框(v-model 双绑 store.searchQuery;Ctrl+K 聚焦) -->
|
|
||||||
<div class="ai-conv-search">
|
|
||||||
<svg class="ai-conv-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
||||||
<input
|
|
||||||
ref="searchInputRef"
|
|
||||||
v-model="store.state.searchQuery"
|
|
||||||
class="ai-conv-search-input"
|
|
||||||
:placeholder="$t('aiChat.searchPlaceholder')"
|
|
||||||
:title="$t('aiChat.searchShortcutHint')"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
<kbd class="ai-conv-search-kbd">{{ $t('aiChat.searchShortcutHint') }}</kbd>
|
|
||||||
</div>
|
|
||||||
<div class="ai-conv-list">
|
|
||||||
<!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC -->
|
|
||||||
<template v-if="searchResults">
|
|
||||||
<div
|
|
||||||
v-for="conv in searchResults"
|
|
||||||
:key="'s-' + conv.id"
|
|
||||||
class="ai-conv-item"
|
|
||||||
:class="{
|
|
||||||
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
|
||||||
'ai-conv-item--archived': conv.archived,
|
|
||||||
}"
|
|
||||||
@click="onSelectSearchResult(conv.id)"
|
|
||||||
>
|
|
||||||
<div class="ai-conv-item-main">
|
|
||||||
<span class="ai-conv-item-title" :title="conv.title || ''">{{ conv.title || $t('aiChat.newConversation') }}</span>
|
|
||||||
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ai-conv-item-actions">
|
|
||||||
<button class="ai-conv-item-act" :class="{ 'ai-conv-item-act--pinned': conv.pinned }" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned)" :title="conv.pinned ? $t('aiChat.unpinConversation') : $t('aiChat.pinConversation')">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ai-conv-item-act" @click.stop="store.archiveConversation(conv.id, !conv.archived)" :title="conv.archived ? $t('aiChat.unarchive') : $t('aiChat.archive')">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
|
||||||
</button>
|
|
||||||
<div class="ai-conv-item-export">
|
|
||||||
<button class="ai-conv-item-act" @click.stop="toggleExportMenu(conv.id)" :title="$t('aiChat.exportConversation')" :disabled="exportingId === conv.id">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
||||||
</button>
|
|
||||||
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ $t('aiChat.exportMarkdown') }}</button>
|
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ $t('aiChat.exportJson') }}</button>
|
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ $t('aiChat.exportTxt') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="ai-conv-item-act" @click.stop="confirmDeleteConversation(conv.id)" :title="$t('common.delete')">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="searchResults.length === 0" class="ai-conv-empty">
|
|
||||||
{{ $t('aiChat.searchEmpty') }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 非搜索态:原时间分组(活跃) + 归档分组 -->
|
|
||||||
<template v-else>
|
|
||||||
<!-- 活跃对话:按 今天/昨天/更早 分组 -->
|
|
||||||
<template v-for="group in groupedActive" :key="'g-' + group.key">
|
|
||||||
<div
|
|
||||||
class="ai-conv-group-title ai-conv-group-title--fold"
|
|
||||||
@click="store.toggleGroupFold(group.key)"
|
|
||||||
>
|
|
||||||
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.foldedGroups[group.key] }">▸</span>
|
|
||||||
{{ group.label }}
|
|
||||||
<span class="ai-conv-group-count">({{ group.items.length }})</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="group.key === 'today' && !group.items.length"
|
|
||||||
v-show="!store.state.foldedGroups[group.key]"
|
|
||||||
class="ai-conv-group-empty"
|
|
||||||
>{{ $t('aiChat.groupEmpty') }}</div>
|
|
||||||
<div
|
|
||||||
v-for="conv in group.items"
|
|
||||||
v-show="!store.state.foldedGroups[group.key]"
|
|
||||||
:key="conv.id"
|
|
||||||
class="ai-conv-item"
|
|
||||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
|
|
||||||
@click="store.switchConversation(conv.id)"
|
|
||||||
>
|
|
||||||
<div class="ai-conv-item-main">
|
|
||||||
<input
|
|
||||||
v-if="editingConvId === conv.id"
|
|
||||||
:ref="setEditEl"
|
|
||||||
v-model="editText"
|
|
||||||
class="ai-conv-item-edit"
|
|
||||||
@keyup.enter="commitRename(conv.id)"
|
|
||||||
@keyup.esc="cancelRename"
|
|
||||||
@blur="commitRename(conv.id)"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
v-else
|
|
||||||
class="ai-conv-item-title"
|
|
||||||
:class="{ 'ai-conv-item-title--flash': titleFlash && conv.id === store.state.activeConversationId }"
|
|
||||||
@dblclick.stop="startRename(conv)"
|
|
||||||
:title="conv.title || $t('aiChat.doubleClickToRename')"
|
|
||||||
>
|
|
||||||
{{ conv.title || $t('aiChat.newConversation') }}
|
|
||||||
<!-- UX-2025-20 §7.4:AI 生成标题标识小图标(标题非空非占位时显示) -->
|
|
||||||
<svg
|
|
||||||
v-if="conv.title && titleFlash && conv.id === store.state.activeConversationId"
|
|
||||||
class="ai-conv-item-title-badge"
|
|
||||||
width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
><polyline points="20 6 9 17 4 12"/></svg>
|
|
||||||
</span>
|
|
||||||
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ai-conv-item-actions">
|
|
||||||
<button class="ai-conv-item-act" :class="{ 'ai-conv-item-act--pinned': conv.pinned }" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned)" :title="conv.pinned ? $t('aiChat.unpinConversation') : $t('aiChat.pinConversation')">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ai-conv-item-act" @click.stop="store.archiveConversation(conv.id, true)" :title="$t('aiChat.archive')">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
|
||||||
</button>
|
|
||||||
<div class="ai-conv-item-export">
|
|
||||||
<button class="ai-conv-item-act" @click.stop="toggleExportMenu(conv.id)" :title="$t('aiChat.exportConversation')" :disabled="exportingId === conv.id">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
||||||
</button>
|
|
||||||
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ $t('aiChat.exportMarkdown') }}</button>
|
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ $t('aiChat.exportJson') }}</button>
|
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ $t('aiChat.exportTxt') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="ai-conv-item-act" @click.stop="confirmDeleteConversation(conv.id)" :title="$t('common.delete')">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 归档分组(可折叠) -->
|
|
||||||
<template v-if="archivedConvs.length">
|
|
||||||
<div class="ai-conv-group-title ai-conv-group-title--fold" @click="store.toggleArchivedFold()">
|
|
||||||
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.archivedCollapsed }">▸</span>
|
|
||||||
{{ $t('aiChat.archivedCount', { n: archivedConvs.length }) }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="conv in archivedConvs"
|
|
||||||
v-show="!store.state.archivedCollapsed"
|
|
||||||
:key="'a-' + conv.id"
|
|
||||||
class="ai-conv-item ai-conv-item--archived"
|
|
||||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
|
|
||||||
@click="store.switchConversation(conv.id)"
|
|
||||||
>
|
|
||||||
<div class="ai-conv-item-main">
|
|
||||||
<span class="ai-conv-item-title" :title="conv.title || ''">{{ conv.title || $t('aiChat.newConversation') }}</span>
|
|
||||||
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ai-conv-item-actions">
|
|
||||||
<button class="ai-conv-item-act" :class="{ 'ai-conv-item-act--pinned': conv.pinned }" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned)" :title="conv.pinned ? $t('aiChat.unpinConversation') : $t('aiChat.pinConversation')">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button class="ai-conv-item-act" @click.stop="store.archiveConversation(conv.id, false)" :title="$t('aiChat.unarchive')">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg>
|
|
||||||
</button>
|
|
||||||
<div class="ai-conv-item-export">
|
|
||||||
<button class="ai-conv-item-act" @click.stop="toggleExportMenu(conv.id)" :title="$t('aiChat.exportConversation')" :disabled="exportingId === conv.id">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
||||||
</button>
|
|
||||||
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ $t('aiChat.exportMarkdown') }}</button>
|
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ $t('aiChat.exportJson') }}</button>
|
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ $t('aiChat.exportTxt') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="ai-conv-item-act" @click.stop="confirmDeleteConversation(conv.id)" :title="$t('common.delete')">
|
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="store.state.conversations.length === 0" class="ai-conv-empty">
|
|
||||||
{{ $t('aiChat.emptyConversation') }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
|
|
||||||
<!-- ═══ 聊天区域 ═══ -->
|
<!-- ═══ 聊天区域 ═══ -->
|
||||||
<div class="ai-chat-area">
|
<div class="ai-chat-area">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -790,12 +594,13 @@ import { useAiStore } from '../stores/ai'
|
|||||||
import { pendingMaxRounds } from '../composables/ai/useAiEvents'
|
import { pendingMaxRounds } from '../composables/ai/useAiEvents'
|
||||||
import { aiApi } from '../api'
|
import { aiApi } from '../api'
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
|
import ConversationSidebar from './ai/ConversationSidebar.vue'
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '../composables/useConfirm'
|
||||||
import { useMarkdown } from '../composables/useMarkdown'
|
import { useMarkdown } from '../composables/useMarkdown'
|
||||||
import ToolCardList from './ToolCardList.vue'
|
import ToolCardList from './ToolCardList.vue'
|
||||||
import { formatRelativeZh, formatDate } from '../utils/time'
|
import { formatRelativeZh, formatDate } from '../utils/time'
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '../stores/project'
|
||||||
import type { AiConversationSummary, AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord } from '../api/types'
|
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord } from '../api/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
detached?: boolean
|
detached?: boolean
|
||||||
@@ -970,29 +775,6 @@ async function sendExamplePrompt(i18nKey: string) {
|
|||||||
await handleSend()
|
await handleSend()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标题生成淡入(UX-2025-20 §7.4):监听当前对话标题从空→非空(后端自动生成标题,
|
|
||||||
// loadConversations 回填侧栏触发),置 titleFlash=true 驱动 CSS opacity/transform 过渡,
|
|
||||||
// 0.3s 后复位允许下次再触发。仅侧栏可见且当前对话项命中时该 class 生效。
|
|
||||||
const titleFlash = ref(false)
|
|
||||||
let _titleFlashTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
const activeConv = computed(() =>
|
|
||||||
store.state.conversations.find(c => c.id === store.state.activeConversationId) || null,
|
|
||||||
)
|
|
||||||
watch(
|
|
||||||
() => activeConv.value?.title,
|
|
||||||
(newTitle, oldTitle) => {
|
|
||||||
// 仅在 标题被生成填充(空/undefined → 非空非"新对话"占位) 时触发淡入;
|
|
||||||
// 用户手动 rename / 切换对话 / 标题无变化 不触发。
|
|
||||||
if (!newTitle) return
|
|
||||||
if (oldTitle && oldTitle === newTitle) return
|
|
||||||
// 切换对话旧 title 非空 → 新 title 非空:不视为"生成",跳过(避免切换闪动)
|
|
||||||
if (oldTitle) return
|
|
||||||
titleFlash.value = true
|
|
||||||
if (_titleFlashTimer) clearTimeout(_titleFlashTimer)
|
|
||||||
_titleFlashTimer = setTimeout(() => { titleFlash.value = false }, 320)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast) ──
|
// ── 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast) ──
|
||||||
// AiChat 可能以分离窗口运行(App.vue 根 toast 不在 DOM 中),故本组件自管一个 toast;
|
// AiChat 可能以分离窗口运行(App.vue 根 toast 不在 DOM 中),故本组件自管一个 toast;
|
||||||
// i18n 不在本任务白名单,文案硬编码,后续补 aiChat.toastSendFail key
|
// i18n 不在本任务白名单,文案硬编码,后续补 aiChat.toastSendFail key
|
||||||
@@ -1173,15 +955,6 @@ async function confirmDeleteConversation(id: string) {
|
|||||||
store.deleteConversation(id)
|
store.deleteConversation(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* UX-260617-23:搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
|
||||||
* 原实现只 switchConversation 不清 searchQuery → 切回的对话在搜索态平铺里,无法回到分组浏览。
|
|
||||||
*/
|
|
||||||
function onSelectSearchResult(id: string) {
|
|
||||||
void store.switchConversation(id)
|
|
||||||
store.state.searchQuery = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清空当前对话(真删 DB messages,带二次确认防误删) */
|
/** 清空当前对话(真删 DB messages,带二次确认防误删) */
|
||||||
async function confirmClearChat() {
|
async function confirmClearChat() {
|
||||||
if (!await confirmDialog(t('aiChat.confirmClearChat'))) return
|
if (!await confirmDialog(t('aiChat.confirmClearChat'))) return
|
||||||
@@ -1402,98 +1175,8 @@ function selectMention(item: MentionItem) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对话项 inline 重命名
|
// 对话侧栏子组件引用(Ctrl+K 快捷键调 focusSearch;子组件自管重命名/导出/拖拽/搜索/分组)
|
||||||
const editingConvId = ref<string | null>(null)
|
const convSidebarRef = ref<InstanceType<typeof ConversationSidebar> | null>(null)
|
||||||
const editText = ref('')
|
|
||||||
// 重命名 input 元素引用。该 input 在 v-for 内且由 v-if(editingConvId===conv.id) 单实例化,
|
|
||||||
// 字符串 ref 在 v-for 中会被收集成数组(.focus/.select 失效),改用函数 ref 直接持有单个元素。
|
|
||||||
let editEl: HTMLInputElement | null = null
|
|
||||||
function setEditEl(el: Element | { $el?: Element } | null): void {
|
|
||||||
editEl = (el as HTMLInputElement) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// UX-06 §3.1: 对话搜索框引用(Ctrl+K 聚焦用)
|
|
||||||
const searchInputRef = ref<HTMLInputElement>()
|
|
||||||
|
|
||||||
function startRename(conv: { id: string; title: string | null }) {
|
|
||||||
editingConvId.value = conv.id
|
|
||||||
editText.value = conv.title || ''
|
|
||||||
nextTick(() => {
|
|
||||||
editEl?.focus()
|
|
||||||
editEl?.select()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commitRename(id: string) {
|
|
||||||
const title = editText.value.trim()
|
|
||||||
if (title && editingConvId.value === id) {
|
|
||||||
await store.renameConversation(id, title)
|
|
||||||
}
|
|
||||||
editingConvId.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelRename() {
|
|
||||||
editingConvId.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UX-18: 对话导出(消费 batch46 ai_conversation_export IPC) ──
|
|
||||||
// 侧栏 hover 浮出导出按钮 → 点开格式小菜单(markdown/json/txt) → 选格式下载文件。
|
|
||||||
// 状态:exportOpenId 控制当前展开的菜单(同一时刻最多一个);exportingId 防重复点击。
|
|
||||||
const exportOpenId = ref<string | null>(null)
|
|
||||||
const exportingId = ref<string | null>(null)
|
|
||||||
|
|
||||||
function toggleExportMenu(id: string) {
|
|
||||||
exportOpenId.value = exportOpenId.value === id ? null : id
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件名清洗:title 可能含路径分隔符/控制字符/emoji,只保留安全子集,空则降级 conv-id
|
|
||||||
function sanitizeFilename(title: string | null | undefined, fallback: string): string {
|
|
||||||
const raw = (title || '').trim()
|
|
||||||
if (!raw) return fallback
|
|
||||||
// 替换 Windows/Unix 路径分隔符与非法字符为下划线,折叠连续空白
|
|
||||||
const cleaned = raw.replace(/[\\/:*?"<>|\r\n\t]+/g, '_').replace(/\s+/g, '_')
|
|
||||||
// 限长 60 防文件名过长;全被清成空(纯符号标题)→ fallback
|
|
||||||
return (cleaned.replace(/^_+|_+$/g, '').slice(0, 60)) || fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportConversation(conv: AiConversationSummary, format: 'markdown' | 'json' | 'txt') {
|
|
||||||
exportOpenId.value = null
|
|
||||||
if (exportingId.value) return // 防重复点击
|
|
||||||
exportingId.value = conv.id
|
|
||||||
try {
|
|
||||||
const content = await aiApi.exportConversation(conv.id, format)
|
|
||||||
const ext = format === 'markdown' ? 'md' : format === 'json' ? 'json' : 'txt'
|
|
||||||
const mime = format === 'markdown' ? 'text/markdown;charset=utf-8'
|
|
||||||
: format === 'json' ? 'application/json;charset=utf-8'
|
|
||||||
: 'text/plain;charset=utf-8'
|
|
||||||
const base = sanitizeFilename(conv.title, conv.id)
|
|
||||||
const filename = `conversation-${base}.${ext}`
|
|
||||||
const blob = new Blob([content], { type: mime })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = filename
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
document.body.removeChild(a)
|
|
||||||
// 释放对象 URL,避免 Blob 内存泄漏(下一轮事件循环已足够触发下载)
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
||||||
} catch (e: any) {
|
|
||||||
const msg = e?.message || String(e)
|
|
||||||
console.error('[AI] 导出对话失败:', e)
|
|
||||||
showToast(t('aiChat.exportFailed', { msg }), 'error')
|
|
||||||
} finally {
|
|
||||||
exportingId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击外部关闭导出菜单(菜单展开时挂,关闭时摘;轻量,避免常驻 listener)
|
|
||||||
function onExportOutsideClick(e: MouseEvent) {
|
|
||||||
if (!exportOpenId.value) return
|
|
||||||
const target = e.target as HTMLElement | null
|
|
||||||
if (target?.closest('.ai-conv-item-export')) return
|
|
||||||
exportOpenId.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeProviderName = computed(() => {
|
const activeProviderName = computed(() => {
|
||||||
const active = store.state.providers.find(p => p.id === store.state.activeProvider)
|
const active = store.state.providers.find(p => p.id === store.state.activeProvider)
|
||||||
@@ -1652,76 +1335,6 @@ function formatTokens(n: number): string {
|
|||||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
|
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化时间戳(毫秒字符串 → 相对时间) */
|
|
||||||
function formatTime(ts: string): string {
|
|
||||||
return formatRelativeZh(ts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 对话分组:今天 / 昨天 / 更早 (走 i18n,跟随语言切换) ──
|
|
||||||
const BUCKET_LABELS = computed<Record<string, string>>(() => ({
|
|
||||||
today: t('aiChat.today'),
|
|
||||||
yesterday: t('aiChat.yesterday'),
|
|
||||||
earlier: t('aiChat.earlier'),
|
|
||||||
}))
|
|
||||||
|
|
||||||
/** 按日历日分桶(非 24h 滚动):today / yesterday / earlier */
|
|
||||||
function timeBucket(ts: string): 'today' | 'yesterday' | 'earlier' {
|
|
||||||
const t = parseInt(ts, 10)
|
|
||||||
if (!Number.isFinite(t)) return 'earlier'
|
|
||||||
const d = new Date(t)
|
|
||||||
const now = new Date()
|
|
||||||
const sameDay = d.getFullYear() === now.getFullYear()
|
|
||||||
&& d.getMonth() === now.getMonth()
|
|
||||||
&& d.getDate() === now.getDate()
|
|
||||||
if (sameDay) return 'today'
|
|
||||||
const yest = new Date(now)
|
|
||||||
yest.setDate(now.getDate() - 1)
|
|
||||||
if (d.getFullYear() === yest.getFullYear()
|
|
||||||
&& d.getMonth() === yest.getMonth()
|
|
||||||
&& d.getDate() === yest.getDate()) return 'yesterday'
|
|
||||||
return 'earlier'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 活跃(非归档)对话按时间桶分组,保持后端 created_at DESC 顺序 */
|
|
||||||
const groupedActive = computed(() => {
|
|
||||||
const map = new Map<string, AiConversationSummary[]>()
|
|
||||||
for (const c of store.state.conversations) {
|
|
||||||
if (c.archived) continue
|
|
||||||
const b = timeBucket(c.updated_at)
|
|
||||||
if (!map.has(b)) map.set(b, [])
|
|
||||||
map.get(b)!.push(c)
|
|
||||||
}
|
|
||||||
// 组内按 pinned DESC, updated_at DESC(置顶在前,同组最近更新的在前;UX-17)
|
|
||||||
for (const items of map.values()) {
|
|
||||||
items.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|
|
||||||
|| Number(b.updated_at) - Number(a.updated_at))
|
|
||||||
}
|
|
||||||
const groups: { key: string; label: string; items: AiConversationSummary[] }[] = []
|
|
||||||
for (const key of ['today', 'yesterday', 'earlier']) {
|
|
||||||
const items = map.get(key) || []
|
|
||||||
// 「今天」恒显(空则占位「暂无」);昨天/更早仅非空时出现
|
|
||||||
if (key === 'today' || items.length) groups.push({ key, label: BUCKET_LABELS.value[key], items })
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 已归档对话(侧栏底部折叠展示;置顶在前,UX-17) */
|
|
||||||
const archivedConvs = computed(() =>
|
|
||||||
store.state.conversations
|
|
||||||
.filter(c => c.archived)
|
|
||||||
.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|
|
||||||
|| Number(b.updated_at) - Number(a.updated_at)),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UX-06 §3.1: 对话搜索结果(解包 store.filteredConversations 给模板用)。
|
|
||||||
* useAiStore 返回普通对象(store.xxx 不经模板自动解包),本组件 computed 显式读 .value,
|
|
||||||
* 模板直接用 searchResults。null=非搜索态(走原分组);数组=平铺结果(可能空)。
|
|
||||||
*/
|
|
||||||
const searchResults = computed<AiConversationSummary[] | null>(
|
|
||||||
() => store.filteredConversations.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── 全局快捷键(window 级,非输入框内 handleKeydown) ──
|
// ── 全局快捷键(window 级,非输入框内 handleKeydown) ──
|
||||||
// 只绑不与浏览器/WebView2 默认冲突的键:
|
// 只绑不与浏览器/WebView2 默认冲突的键:
|
||||||
// - Ctrl+L(浏览器聚焦地址栏——WebView2 内测,preventDefault 通常有效)
|
// - Ctrl+L(浏览器聚焦地址栏——WebView2 内测,preventDefault 通常有效)
|
||||||
@@ -1745,7 +1358,8 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|||||||
if (ctrl && (e.key === 'k' || e.key === 'K')) {
|
if (ctrl && (e.key === 'k' || e.key === 'K')) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!store.state.sidebarOpen) store.toggleSidebar()
|
if (!store.state.sidebarOpen) store.toggleSidebar()
|
||||||
nextTick(() => searchInputRef.value?.focus())
|
// 经 ConversationSidebar expose 的 focusSearch 聚焦搜索框(等价原 searchInputRef.value?.focus())
|
||||||
|
nextTick(() => convSidebarRef.value?.focusSearch())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Ctrl+B: 切侧栏宽度/显隐(复用 store.toggleSidebar)
|
// Ctrl+B: 切侧栏宽度/显隐(复用 store.toggleSidebar)
|
||||||
@@ -1795,30 +1409,6 @@ function onBeforeUnloadGuard(e: BeforeUnloadEvent) {
|
|||||||
e.returnValue = ''
|
e.returnValue = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UX-2025-16: 侧栏宽度拖拽 ──
|
|
||||||
// 生命周期:mousedown 时临时挂 document mousemove/mouseup,mouseup 时摘除(非常驻 onMounted)。
|
|
||||||
// 对齐 onGlobalKeydown 的 listener 思路,但拖拽监听只活在拖拽期间。
|
|
||||||
// 拖拽中 body cursor 锁 col-resize 防止 mousemove 跨元素时 cursor 抖动;user-select:none 防选中文本。
|
|
||||||
const resizeState = { startX: 0, startWidth: 0 }
|
|
||||||
function onResizeMove(e: MouseEvent) {
|
|
||||||
// delta>0 鼠标右移 → 侧栏变宽(右边缘向右扩)。clamp 由 setSidebarWidth 内部统一兜底
|
|
||||||
store.setSidebarWidth(resizeState.startWidth + (e.clientX - resizeState.startX))
|
|
||||||
}
|
|
||||||
function onResizeEnd() {
|
|
||||||
document.removeEventListener('mousemove', onResizeMove)
|
|
||||||
document.removeEventListener('mouseup', onResizeEnd)
|
|
||||||
document.body.classList.remove('ai-sidebar-resizing')
|
|
||||||
// mouseup 时一次性持久化(拖拽中高频 setSidebarWidth 只改 state 不落盘)
|
|
||||||
store.persistUiState()
|
|
||||||
}
|
|
||||||
function onResizeStart(e: MouseEvent) {
|
|
||||||
resizeState.startX = e.clientX
|
|
||||||
resizeState.startWidth = store.state.sidebarWidth
|
|
||||||
document.body.classList.add('ai-sidebar-resizing')
|
|
||||||
document.addEventListener('mousemove', onResizeMove)
|
|
||||||
document.addEventListener('mouseup', onResizeEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
// UX-09:编辑态 Escape 取消编辑(最高优先级)
|
// UX-09:编辑态 Escape 取消编辑(最高优先级)
|
||||||
if (editingMsgId.value && e.key === 'Escape') {
|
if (editingMsgId.value && e.key === 'Escape') {
|
||||||
@@ -2127,14 +1717,9 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('keydown', onGlobalKeydown)
|
window.removeEventListener('keydown', onGlobalKeydown)
|
||||||
// UX-260617-16:摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
|
// UX-260617-16:摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
|
||||||
window.removeEventListener('beforeunload', onBeforeUnloadGuard)
|
window.removeEventListener('beforeunload', onBeforeUnloadGuard)
|
||||||
// UX-18: 摘除导出菜单外部点击监听
|
// 注:导出菜单外部点击监听/侧栏拖拽 listener/标题淡入计时器现由 ConversationSidebar
|
||||||
document.removeEventListener('click', onExportOutsideClick)
|
// 子组件 onBeforeUnmount 自管(第一批抽离),父不再持有这些引用。
|
||||||
if (rafId !== null) cancelAnimationFrame(rafId)
|
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||||
// UX-2025-16:防御性清理——组件在拖拽中途被卸载(分离窗口切换)时摘除临时 document listener,
|
|
||||||
// 否则 mousemove/mouseup 残留,下次进面板可能误触发。正常 mouseup 已自行摘除,这里兜底。
|
|
||||||
document.removeEventListener('mousemove', onResizeMove)
|
|
||||||
document.removeEventListener('mouseup', onResizeEnd)
|
|
||||||
document.body.classList.remove('ai-sidebar-resizing')
|
|
||||||
// CR-260615-24: 释放后端 listener(ai-chat-event/ai-conversation-changed)+清流式看门狗。
|
// CR-260615-24: 释放后端 listener(ai-chat-event/ai-conversation-changed)+清流式看门狗。
|
||||||
// 之前 stopListener 定义但全仓零调用 → listener+watchdog 永久泄漏;分离窗口主窗口 AiChat
|
// 之前 stopListener 定义但全仓零调用 → listener+watchdog 永久泄漏;分离窗口主窗口 AiChat
|
||||||
// 卸载(走 App.vue v-if detach)时 listener 仍挂后端,watchdog 130s 后写已卸载 state。
|
// 卸载(走 App.vue v-if detach)时 listener 仍挂后端,watchdog 130s 后写已卸载 state。
|
||||||
@@ -2144,8 +1729,6 @@ onBeforeUnmount(() => {
|
|||||||
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
|
||||||
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
|
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
|
||||||
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
|
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
|
||||||
// UX-2025-20:释放标题淡入计时器
|
|
||||||
if (_titleFlashTimer) { clearTimeout(_titleFlashTimer); _titleFlashTimer = null }
|
|
||||||
// UX-2025-14:释放 provider 切换 bar 计时器
|
// UX-2025-14:释放 provider 切换 bar 计时器
|
||||||
if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null }
|
if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null }
|
||||||
})
|
})
|
||||||
@@ -2220,8 +1803,7 @@ onMounted(async () => {
|
|||||||
// pendingSkill,经 beforeunload 拦截提示用户。注意:e.preventDefault() 才是现代浏览器
|
// pendingSkill,经 beforeunload 拦截提示用户。注意:e.preventDefault() 才是现代浏览器
|
||||||
// 触发原生「确定离开?」的标准做法 returnValue 仅 Chrome 兼容旧写法。
|
// 触发原生「确定离开?」的标准做法 returnValue 仅 Chrome 兼容旧写法。
|
||||||
window.addEventListener('beforeunload', onBeforeUnloadGuard)
|
window.addEventListener('beforeunload', onBeforeUnloadGuard)
|
||||||
// UX-18: 点击外部关闭导出格式菜单
|
// 注:导出菜单外部点击监听现由 ConversationSidebar 子组件 onMounted 自管(第一批抽离)
|
||||||
document.addEventListener('click', onExportOutsideClick)
|
|
||||||
const t0 = (window as any).__APP_T0 ?? 0
|
const t0 = (window as any).__APP_T0 ?? 0
|
||||||
// 启动耗时埋点:debug 级(浏览器默认折叠不干扰,空白屏复现时可开 verbose 看)
|
// 启动耗时埋点:debug 级(浏览器默认折叠不干扰,空白屏复现时可开 verbose 看)
|
||||||
console.debug(`[启动] AiChat mounted: ${(performance.now() - t0).toFixed(0)}ms`)
|
console.debug(`[启动] AiChat mounted: ${(performance.now() - t0).toFixed(0)}ms`)
|
||||||
|
|||||||
457
src/components/ai/ConversationSidebar.vue
Normal file
457
src/components/ai/ConversationSidebar.vue
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
<template>
|
||||||
|
<!-- ═══ 对话侧边栏(抽自 AiChat.vue 第一批,零行为变更) ═══ -->
|
||||||
|
<transition name="sidebar-slide">
|
||||||
|
<div
|
||||||
|
v-if="store.state.sidebarOpen"
|
||||||
|
class="ai-conv-sidebar"
|
||||||
|
:style="{ '--sidebar-width': store.state.sidebarWidth + 'px' }"
|
||||||
|
>
|
||||||
|
<!-- 侧栏右边缘拖拽条(UX-2025-16):col-resize,拖动 mousemove 改 sidebarWidth,mouseup 持久化 -->
|
||||||
|
<div
|
||||||
|
class="ai-sidebar-resize-handle"
|
||||||
|
:title="t('aiChat.sidebarResizeHint')"
|
||||||
|
@mousedown.prevent="onResizeStart"
|
||||||
|
/>
|
||||||
|
<div class="ai-conv-sidebar-header">
|
||||||
|
<span class="ai-conv-sidebar-title">{{ t('aiChat.sidebarTitle') }}</span>
|
||||||
|
<button class="ai-btn-icon" @click="emit('confirm-new-conversation')" :title="t('aiChat.newConversation')">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- UX-06 §3.1: 对话搜索框(v-model 双绑 store.searchQuery;Ctrl+K 聚焦) -->
|
||||||
|
<div class="ai-conv-search">
|
||||||
|
<svg class="ai-conv-search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input
|
||||||
|
ref="searchInputRef"
|
||||||
|
v-model="store.state.searchQuery"
|
||||||
|
class="ai-conv-search-input"
|
||||||
|
:placeholder="t('aiChat.searchPlaceholder')"
|
||||||
|
:title="t('aiChat.searchShortcutHint')"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<kbd class="ai-conv-search-kbd">{{ t('aiChat.searchShortcutHint') }}</kbd>
|
||||||
|
</div>
|
||||||
|
<div class="ai-conv-list">
|
||||||
|
<!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC -->
|
||||||
|
<template v-if="searchResults">
|
||||||
|
<div
|
||||||
|
v-for="conv in searchResults"
|
||||||
|
:key="'s-' + conv.id"
|
||||||
|
class="ai-conv-item"
|
||||||
|
:class="{
|
||||||
|
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
||||||
|
'ai-conv-item--archived': conv.archived,
|
||||||
|
}"
|
||||||
|
@click="onSelectSearchResult(conv.id)"
|
||||||
|
>
|
||||||
|
<div class="ai-conv-item-main">
|
||||||
|
<span class="ai-conv-item-title" :title="conv.title || ''">{{ conv.title || t('aiChat.newConversation') }}</span>
|
||||||
|
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ai-conv-item-actions">
|
||||||
|
<button class="ai-conv-item-act" :class="{ 'ai-conv-item-act--pinned': conv.pinned }" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned)" :title="conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation')">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ai-conv-item-act" @click.stop="store.archiveConversation(conv.id, !conv.archived)" :title="conv.archived ? t('aiChat.unarchive') : t('aiChat.archive')">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="ai-conv-item-export">
|
||||||
|
<button class="ai-conv-item-act" @click.stop="toggleExportMenu(conv.id)" :title="t('aiChat.exportConversation')" :disabled="exportingId === conv.id">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
||||||
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ t('aiChat.exportMarkdown') }}</button>
|
||||||
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ t('aiChat.exportJson') }}</button>
|
||||||
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ t('aiChat.exportTxt') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="ai-conv-item-act" @click.stop="emit('confirm-delete', conv.id)" :title="t('common.delete')">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="searchResults.length === 0" class="ai-conv-empty">
|
||||||
|
{{ t('aiChat.searchEmpty') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 非搜索态:原时间分组(活跃) + 归档分组 -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- 活跃对话:按 今天/昨天/更早 分组 -->
|
||||||
|
<template v-for="group in groupedActive" :key="'g-' + group.key">
|
||||||
|
<div
|
||||||
|
class="ai-conv-group-title ai-conv-group-title--fold"
|
||||||
|
@click="store.toggleGroupFold(group.key)"
|
||||||
|
>
|
||||||
|
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.foldedGroups[group.key] }">▸</span>
|
||||||
|
{{ group.label }}
|
||||||
|
<span class="ai-conv-group-count">({{ group.items.length }})</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="group.key === 'today' && !group.items.length"
|
||||||
|
v-show="!store.state.foldedGroups[group.key]"
|
||||||
|
class="ai-conv-group-empty"
|
||||||
|
>{{ t('aiChat.groupEmpty') }}</div>
|
||||||
|
<div
|
||||||
|
v-for="conv in group.items"
|
||||||
|
v-show="!store.state.foldedGroups[group.key]"
|
||||||
|
:key="conv.id"
|
||||||
|
class="ai-conv-item"
|
||||||
|
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
|
||||||
|
@click="store.switchConversation(conv.id)"
|
||||||
|
>
|
||||||
|
<div class="ai-conv-item-main">
|
||||||
|
<input
|
||||||
|
v-if="editingConvId === conv.id"
|
||||||
|
:ref="setEditEl"
|
||||||
|
v-model="editText"
|
||||||
|
class="ai-conv-item-edit"
|
||||||
|
@keyup.enter="commitRename(conv.id)"
|
||||||
|
@keyup.esc="cancelRename"
|
||||||
|
@blur="commitRename(conv.id)"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="ai-conv-item-title"
|
||||||
|
:class="{ 'ai-conv-item-title--flash': titleFlash && conv.id === store.state.activeConversationId }"
|
||||||
|
@dblclick.stop="startRename(conv)"
|
||||||
|
:title="conv.title || t('aiChat.doubleClickToRename')"
|
||||||
|
>
|
||||||
|
{{ conv.title || t('aiChat.newConversation') }}
|
||||||
|
<!-- UX-2025-20 §7.4:AI 生成标题标识小图标(标题非空非占位时显示) -->
|
||||||
|
<svg
|
||||||
|
v-if="conv.title && titleFlash && conv.id === store.state.activeConversationId"
|
||||||
|
class="ai-conv-item-title-badge"
|
||||||
|
width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ai-conv-item-actions">
|
||||||
|
<button class="ai-conv-item-act" :class="{ 'ai-conv-item-act--pinned': conv.pinned }" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned)" :title="conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation')">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ai-conv-item-act" @click.stop="store.archiveConversation(conv.id, true)" :title="t('aiChat.archive')">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="10" y1="13" x2="14" y2="13"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="ai-conv-item-export">
|
||||||
|
<button class="ai-conv-item-act" @click.stop="toggleExportMenu(conv.id)" :title="t('aiChat.exportConversation')" :disabled="exportingId === conv.id">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
||||||
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ t('aiChat.exportMarkdown') }}</button>
|
||||||
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ t('aiChat.exportJson') }}</button>
|
||||||
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ t('aiChat.exportTxt') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="ai-conv-item-act" @click.stop="emit('confirm-delete', conv.id)" :title="t('common.delete')">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 归档分组(可折叠) -->
|
||||||
|
<template v-if="archivedConvs.length">
|
||||||
|
<div class="ai-conv-group-title ai-conv-group-title--fold" @click="store.toggleArchivedFold()">
|
||||||
|
<span class="ai-conv-group-arrow" :class="{ 'is-folded': store.state.archivedCollapsed }">▸</span>
|
||||||
|
{{ t('aiChat.archivedCount', { n: archivedConvs.length }) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="conv in archivedConvs"
|
||||||
|
v-show="!store.state.archivedCollapsed"
|
||||||
|
:key="'a-' + conv.id"
|
||||||
|
class="ai-conv-item ai-conv-item--archived"
|
||||||
|
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
|
||||||
|
@click="store.switchConversation(conv.id)"
|
||||||
|
>
|
||||||
|
<div class="ai-conv-item-main">
|
||||||
|
<span class="ai-conv-item-title" :title="conv.title || ''">{{ conv.title || t('aiChat.newConversation') }}</span>
|
||||||
|
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ai-conv-item-actions">
|
||||||
|
<button class="ai-conv-item-act" :class="{ 'ai-conv-item-act--pinned': conv.pinned }" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned)" :title="conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation')">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24V16a1 1 0 001 1h12a1 1 0 001-1v-.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V7a1 1 0 011-1 2 2 0 002-2V3H6v1a2 2 0 002 2 1 1 0 011 1z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ai-conv-item-act" @click.stop="store.archiveConversation(conv.id, false)" :title="t('aiChat.unarchive')">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="5" rx="1"/><path d="M4 9v9a2 2 0 002 2h12a2 2 0 002-2V9"/><line x1="12" y1="13" x2="12" y2="17"/><polyline points="10 15 12 13 14 15"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="ai-conv-item-export">
|
||||||
|
<button class="ai-conv-item-act" @click.stop="toggleExportMenu(conv.id)" :title="t('aiChat.exportConversation')" :disabled="exportingId === conv.id">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
||||||
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ t('aiChat.exportMarkdown') }}</button>
|
||||||
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ t('aiChat.exportJson') }}</button>
|
||||||
|
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ t('aiChat.exportTxt') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="ai-conv-item-act" @click.stop="emit('confirm-delete', conv.id)" :title="t('common.delete')">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="store.state.conversations.length === 0" class="ai-conv-empty">
|
||||||
|
{{ t('aiChat.emptyConversation') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 对话侧边栏 — 抽自 AiChat.vue 第一批重构(零行为变更)。
|
||||||
|
*
|
||||||
|
* 设计:
|
||||||
|
* - store 共享(useAiStore 全局单例),子组件直接 useAiStore 读写 store.state/mutations,
|
||||||
|
* 与父组件 AiChat 等价(策略:store 共享则两边都用 useXxxStore,不走 props 传 store)。
|
||||||
|
* - 跨组件事件走 emit:
|
||||||
|
* - `confirm-delete`(id):删除需弹 ConfirmDialog,而 ConfirmDialog 由父渲染 +
|
||||||
|
* confirmDialog 实例归父所有(useConfirm 每次调用返回独立 ref,非全局单例),
|
||||||
|
* 故删除确认交父处理。父调原 confirmDeleteConversation(id)。
|
||||||
|
* - `confirm-new-conversation`:新建对话(生成中走确认弹层),交父 confirmNewConversation。
|
||||||
|
* - `export-error`(msg):导出失败 toast,父 showToast(分离窗口自管 toast)。
|
||||||
|
* - 父 Ctrl+K 全局快捷键聚焦搜索框:经 defineExpose({ focusSearch }) 供父调,
|
||||||
|
* 父侧 ref 替代原 searchInputRef(零行为变更)。
|
||||||
|
*
|
||||||
|
* 本组件自管的纯侧栏态:titleFlash/重命名/导出菜单/侧栏拖拽/搜索结果/时间分组。
|
||||||
|
* 样式:零 scoped style(沿用 AiChat.vue 全局/父级样式,样式后续批下沉)。
|
||||||
|
*/
|
||||||
|
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useAiStore } from '../../stores/ai'
|
||||||
|
import { aiApi } from '../../api'
|
||||||
|
import { formatRelativeZh } from '../../utils/time'
|
||||||
|
import type { AiConversationSummary } from '../../api/types'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 删除对话(需二次确认),父调 confirmDeleteConversation(id) */
|
||||||
|
(e: 'confirm-delete', id: string): void
|
||||||
|
/** 新建对话(生成中需二次确认),父调 confirmNewConversation() */
|
||||||
|
(e: 'confirm-new-conversation'): void
|
||||||
|
/** 导出失败,父 showToast(msg, 'error') */
|
||||||
|
(e: 'export-error', msg: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const store = useAiStore()
|
||||||
|
|
||||||
|
// ── 标题生成淡入(UX-2025-20 §7.4):监听当前对话标题从空→非空触发 ──
|
||||||
|
const titleFlash = ref(false)
|
||||||
|
let _titleFlashTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const activeConv = computed(() =>
|
||||||
|
store.state.conversations.find(c => c.id === store.state.activeConversationId) || null,
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => activeConv.value?.title,
|
||||||
|
(newTitle, oldTitle) => {
|
||||||
|
if (!newTitle) return
|
||||||
|
if (oldTitle && oldTitle === newTitle) return
|
||||||
|
if (oldTitle) return
|
||||||
|
titleFlash.value = true
|
||||||
|
if (_titleFlashTimer) clearTimeout(_titleFlashTimer)
|
||||||
|
_titleFlashTimer = setTimeout(() => { titleFlash.value = false }, 320)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── 对话项 inline 重命名 ──
|
||||||
|
const editingConvId = ref<string | null>(null)
|
||||||
|
const editText = ref('')
|
||||||
|
let editEl: HTMLInputElement | null = null
|
||||||
|
function setEditEl(el: Element | { $el?: Element } | null): void {
|
||||||
|
editEl = (el as HTMLInputElement) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// UX-06 §3.1: 对话搜索框引用(Ctrl+K 聚焦用)
|
||||||
|
const searchInputRef = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
/** 父 Ctrl+K 快捷键调用:聚焦搜索框(等价原 searchInputRef.value?.focus()) */
|
||||||
|
function focusSearch(): void {
|
||||||
|
nextTick(() => searchInputRef.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(conv: { id: string; title: string | null }) {
|
||||||
|
editingConvId.value = conv.id
|
||||||
|
editText.value = conv.title || ''
|
||||||
|
nextTick(() => {
|
||||||
|
editEl?.focus()
|
||||||
|
editEl?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitRename(id: string) {
|
||||||
|
const title = editText.value.trim()
|
||||||
|
if (title && editingConvId.value === id) {
|
||||||
|
await store.renameConversation(id, title)
|
||||||
|
}
|
||||||
|
editingConvId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
editingConvId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UX-18: 对话导出 ──
|
||||||
|
const exportOpenId = ref<string | null>(null)
|
||||||
|
const exportingId = ref<string | null>(null)
|
||||||
|
|
||||||
|
function toggleExportMenu(id: string) {
|
||||||
|
exportOpenId.value = exportOpenId.value === id ? null : id
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFilename(title: string | null | undefined, fallback: string): string {
|
||||||
|
const raw = (title || '').trim()
|
||||||
|
if (!raw) return fallback
|
||||||
|
const cleaned = raw.replace(/[\\/:*?"<>|\r\n\t]+/g, '_').replace(/\s+/g, '_')
|
||||||
|
return (cleaned.replace(/^_+|_+$/g, '').slice(0, 60)) || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportConversation(conv: AiConversationSummary, format: 'markdown' | 'json' | 'txt') {
|
||||||
|
exportOpenId.value = null
|
||||||
|
if (exportingId.value) return
|
||||||
|
exportingId.value = conv.id
|
||||||
|
try {
|
||||||
|
const content = await aiApi.exportConversation(conv.id, format)
|
||||||
|
const ext = format === 'markdown' ? 'md' : format === 'json' ? 'json' : 'txt'
|
||||||
|
const mime = format === 'markdown' ? 'text/markdown;charset=utf-8'
|
||||||
|
: format === 'json' ? 'application/json;charset=utf-8'
|
||||||
|
: 'text/plain;charset=utf-8'
|
||||||
|
const base = sanitizeFilename(conv.title, conv.id)
|
||||||
|
const filename = `conversation-${base}.${ext}`
|
||||||
|
const blob = new Blob([content], { type: mime })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.message || String(e)
|
||||||
|
console.error('[AI] 导出对话失败:', e)
|
||||||
|
emit('export-error', t('aiChat.exportFailed', { msg }))
|
||||||
|
} finally {
|
||||||
|
exportingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭导出菜单
|
||||||
|
function onExportOutsideClick(e: MouseEvent) {
|
||||||
|
if (!exportOpenId.value) return
|
||||||
|
const target = e.target as HTMLElement | null
|
||||||
|
if (target?.closest('.ai-conv-item-export')) return
|
||||||
|
exportOpenId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UX-260617-23:搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
||||||
|
*/
|
||||||
|
function onSelectSearchResult(id: string) {
|
||||||
|
void store.switchConversation(id)
|
||||||
|
store.state.searchQuery = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: string): string {
|
||||||
|
return formatRelativeZh(ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||||
|
const BUCKET_LABELS = computed<Record<string, string>>(() => ({
|
||||||
|
today: t('aiChat.today'),
|
||||||
|
yesterday: t('aiChat.yesterday'),
|
||||||
|
earlier: t('aiChat.earlier'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function timeBucket(ts: string): 'today' | 'yesterday' | 'earlier' {
|
||||||
|
const t = parseInt(ts, 10)
|
||||||
|
if (!Number.isFinite(t)) return 'earlier'
|
||||||
|
const d = new Date(t)
|
||||||
|
const now = new Date()
|
||||||
|
const sameDay = d.getFullYear() === now.getFullYear()
|
||||||
|
&& d.getMonth() === now.getMonth()
|
||||||
|
&& d.getDate() === now.getDate()
|
||||||
|
if (sameDay) return 'today'
|
||||||
|
const yest = new Date(now)
|
||||||
|
yest.setDate(now.getDate() - 1)
|
||||||
|
if (d.getFullYear() === yest.getFullYear()
|
||||||
|
&& d.getMonth() === yest.getMonth()
|
||||||
|
&& d.getDate() === yest.getDate()) return 'yesterday'
|
||||||
|
return 'earlier'
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedActive = computed(() => {
|
||||||
|
const map = new Map<string, AiConversationSummary[]>()
|
||||||
|
for (const c of store.state.conversations) {
|
||||||
|
if (c.archived) continue
|
||||||
|
const b = timeBucket(c.updated_at)
|
||||||
|
if (!map.has(b)) map.set(b, [])
|
||||||
|
map.get(b)!.push(c)
|
||||||
|
}
|
||||||
|
for (const items of map.values()) {
|
||||||
|
items.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|
||||||
|
|| Number(b.updated_at) - Number(a.updated_at))
|
||||||
|
}
|
||||||
|
const groups: { key: string; label: string; items: AiConversationSummary[] }[] = []
|
||||||
|
for (const key of ['today', 'yesterday', 'earlier']) {
|
||||||
|
const items = map.get(key) || []
|
||||||
|
if (key === 'today' || items.length) groups.push({ key, label: BUCKET_LABELS.value[key], items })
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
const archivedConvs = computed(() =>
|
||||||
|
store.state.conversations
|
||||||
|
.filter(c => c.archived)
|
||||||
|
.sort((a, b) => Number(b.pinned ? 1 : 0) - Number(a.pinned ? 1 : 0)
|
||||||
|
|| Number(b.updated_at) - Number(a.updated_at)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const searchResults = computed<AiConversationSummary[] | null>(
|
||||||
|
() => store.filteredConversations.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── UX-2025-16: 侧栏宽度拖拽 ──
|
||||||
|
const resizeState = { startX: 0, startWidth: 0 }
|
||||||
|
function onResizeMove(e: MouseEvent) {
|
||||||
|
store.setSidebarWidth(resizeState.startWidth + (e.clientX - resizeState.startX))
|
||||||
|
}
|
||||||
|
function onResizeEnd() {
|
||||||
|
document.removeEventListener('mousemove', onResizeMove)
|
||||||
|
document.removeEventListener('mouseup', onResizeEnd)
|
||||||
|
document.body.classList.remove('ai-sidebar-resizing')
|
||||||
|
store.persistUiState()
|
||||||
|
}
|
||||||
|
function onResizeStart(e: MouseEvent) {
|
||||||
|
resizeState.startX = e.clientX
|
||||||
|
resizeState.startWidth = store.state.sidebarWidth
|
||||||
|
document.body.classList.add('ai-sidebar-resizing')
|
||||||
|
document.addEventListener('mousemove', onResizeMove)
|
||||||
|
document.addEventListener('mouseup', onResizeEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// UX-18: 点击外部关闭导出格式菜单(原 AiChat onMounted 同段迁移)
|
||||||
|
document.addEventListener('click', onExportOutsideClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onExportOutsideClick)
|
||||||
|
// UX-2025-16:拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
|
||||||
|
document.removeEventListener('mousemove', onResizeMove)
|
||||||
|
document.removeEventListener('mouseup', onResizeEnd)
|
||||||
|
document.body.classList.remove('ai-sidebar-resizing')
|
||||||
|
// UX-2025-20:释放标题淡入计时器
|
||||||
|
if (_titleFlashTimer) { clearTimeout(_titleFlashTimer); _titleFlashTimer = null }
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
/** 聚焦搜索框(父 Ctrl+K 快捷键调,等价原 searchInputRef.value?.focus()) */
|
||||||
|
focusSearch,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user