重构: 拆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:
lxy
2026-06-19 03:54:31 +08:00
parent 6a75c11dfa
commit 4eaff04ac1
2 changed files with 473 additions and 434 deletions
+457
View 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>