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

View File

@@ -1,208 +1,12 @@
<template>
<div class="ai-panel">
<!-- 对话侧边栏 -->
<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="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>
<!-- 对话侧边栏(第一批抽离至 ai/ConversationSidebar.vue,零行为变更) -->
<ConversationSidebar
ref="convSidebarRef"
@confirm-delete="confirmDeleteConversation"
@confirm-new-conversation="confirmNewConversation"
@export-error="(msg: string) => showToast(msg, 'error')"
/>
<!-- ═══ 聊天区域 ═══ -->
<div class="ai-chat-area">
<!-- Header -->
@@ -790,12 +594,13 @@ import { useAiStore } from '../stores/ai'
import { pendingMaxRounds } from '../composables/ai/useAiEvents'
import { aiApi } from '../api'
import ConfirmDialog from './ConfirmDialog.vue'
import ConversationSidebar from './ai/ConversationSidebar.vue'
import { useConfirm } from '../composables/useConfirm'
import { useMarkdown } from '../composables/useMarkdown'
import ToolCardList from './ToolCardList.vue'
import { formatRelativeZh, formatDate } from '../utils/time'
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<{
detached?: boolean
@@ -970,29 +775,6 @@ async function sendExamplePrompt(i18nKey: string) {
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) ──
// AiChat 可能以分离窗口运行(App.vue 根 toast 不在 DOM 中),故本组件自管一个 toast;
// i18n 不在本任务白名单,文案硬编码,后续补 aiChat.toastSendFail key
@@ -1173,15 +955,6 @@ async function confirmDeleteConversation(id: string) {
store.deleteConversation(id)
}
/**
* UX-260617-23:搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
* 原实现只 switchConversation 不清 searchQuery → 切回的对话在搜索态平铺里,无法回到分组浏览。
*/
function onSelectSearchResult(id: string) {
void store.switchConversation(id)
store.state.searchQuery = ''
}
/** 清空当前对话(真删 DB messages,带二次确认防误删) */
async function confirmClearChat() {
if (!await confirmDialog(t('aiChat.confirmClearChat'))) return
@@ -1402,98 +1175,8 @@ function selectMention(item: MentionItem) {
})
}
// 对话项 inline 重命名
const editingConvId = ref<string | 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
}
// 对话侧栏子组件引用(Ctrl+K 快捷键调 focusSearch;子组件自管重命名/导出/拖拽/搜索/分组)
const convSidebarRef = ref<InstanceType<typeof ConversationSidebar> | null>(null)
const activeProviderName = computed(() => {
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)
}
/** 格式化时间戳(毫秒字符串 → 相对时间) */
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) ──
// 只绑不与浏览器/WebView2 默认冲突的键:
// - Ctrl+L(浏览器聚焦地址栏——WebView2 内测,preventDefault 通常有效)
@@ -1745,7 +1358,8 @@ function onGlobalKeydown(e: KeyboardEvent) {
if (ctrl && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
if (!store.state.sidebarOpen) store.toggleSidebar()
nextTick(() => searchInputRef.value?.focus())
// 经 ConversationSidebar expose 的 focusSearch 聚焦搜索框(等价原 searchInputRef.value?.focus())
nextTick(() => convSidebarRef.value?.focusSearch())
return
}
// Ctrl+B: 切侧栏宽度/显隐(复用 store.toggleSidebar)
@@ -1795,30 +1409,6 @@ function onBeforeUnloadGuard(e: BeforeUnloadEvent) {
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) {
// UX-09:编辑态 Escape 取消编辑(最高优先级)
if (editingMsgId.value && e.key === 'Escape') {
@@ -2127,14 +1717,9 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', onGlobalKeydown)
// UX-260617-16:摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
window.removeEventListener('beforeunload', onBeforeUnloadGuard)
// UX-18: 摘除导出菜单外部点击监听
document.removeEventListener('click', onExportOutsideClick)
// 注:导出菜单外部点击监听/侧栏拖拽 listener/标题淡入计时器现由 ConversationSidebar
// 子组件 onBeforeUnmount 自管(第一批抽离),父不再持有这些引用。
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)+清流式看门狗。
// 之前 stopListener 定义但全仓零调用 → listener+watchdog 永久泄漏;分离窗口主窗口 AiChat
// 卸载(走 App.vue v-if detach)时 listener 仍挂后端,watchdog 130s 后写已卸载 state。
@@ -2144,8 +1729,6 @@ onBeforeUnmount(() => {
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
// UX-2025-20:释放标题淡入计时器
if (_titleFlashTimer) { clearTimeout(_titleFlashTimer); _titleFlashTimer = null }
// UX-2025-14:释放 provider 切换 bar 计时器
if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null }
})
@@ -2220,8 +1803,7 @@ onMounted(async () => {
// pendingSkill,经 beforeunload 拦截提示用户。注意:e.preventDefault() 才是现代浏览器
// 触发原生「确定离开?」的标准做法 returnValue 仅 Chrome 兼容旧写法。
window.addEventListener('beforeunload', onBeforeUnloadGuard)
// UX-18: 点击外部关闭导出格式菜单
document.addEventListener('click', onExportOutsideClick)
// 注:导出菜单外部点击监听现由 ConversationSidebar 子组件 onMounted 自管(第一批抽离)
const t0 = (window as any).__APP_T0 ?? 0
// 启动耗时埋点:debug 级(浏览器默认折叠不干扰,空白屏复现时可开 verbose 看)
console.debug(`[启动] AiChat mounted: ${(performance.now() - t0).toFixed(0)}ms`)

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>