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