重构: 拆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>
|
||||
<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`)
|
||||
|
||||
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