修复: 主菜单点不开 + 对话列表样式/菜单 + 标题卡死/弹回/updated_at(God 拆分遗漏 + 多 bug 合并)
主菜单点不开(7f1242c):c38cefe ErrorBoundary 嵌 transition 内部,slot 透传非元素根 → mode="out-in" 过渡卡死 → 点主菜单 URL 变页面不切换。修:ErrorBoundary 移 transition 外 + 根固定 div.error-boundary-root + display:contents 对话列表样式/菜单(God 拆分遗漏4f8f1cf+ 脉冲点5c395f4+ 审查 agent 菜单): - God 拆分样式没移子组件,AiChat scoped 不匹配子元素 → 列表/输入框样式丢。修:样式移 ConversationSidebar/ChatInput scoped + 复用抽 components.css - 脉冲点 vertical-align/margin-left 冲突。修:移除用 flex gap 统一 - 4 操作按钮窄宽撑高 → 收进"..."下拉菜单(置顶/归档/删除)+ 移除导出(后端 IPC 保留) 标题 3 重根因(8192cd2 合并): - 卡死:ensure :40 title.is_some() return,"新对话"占位永远跳过;switch need_title_regen 仅 None 不触发。修:排除"新对话"占位 + need_title_regen 含"新对话" - 弹回::121 无条件落 LLM 结果,clean_title 空兜底"新对话"覆盖 extract。修:非空非"新对话"才落 - updated_at 跳变:update_field 强制 SET updated_at=now,标题生成致时间分组跳。修:set_title 不动 updated_at CR-20/21 审查登记(待审查.md) cargo check + vue-tsc + vite build EXIT 0
This commit is contained in:
@@ -5,7 +5,6 @@
|
||||
ref="convSidebarRef"
|
||||
@confirm-delete="confirmDeleteConversation"
|
||||
@confirm-new-conversation="confirmNewConversation"
|
||||
@export-error="(msg: string) => showToast(msg, 'error')"
|
||||
/>
|
||||
<!-- ═══ 聊天区域 ═══ -->
|
||||
<div class="ai-chat-area">
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<!-- 全局错误兜底:子树渲染/生命周期抛错时显示降级 UI,防止白屏。
|
||||
用法:用 <ErrorBoundary> 包裹可能出错的子组件树(slot 透传)。 -->
|
||||
<template v-if="error">
|
||||
<div class="error-boundary">
|
||||
用法:用 <ErrorBoundary> 包裹可能出错的子组件树(slot 透传)。
|
||||
根节点固定为单个 <div>(display:contents 布局透明),避免被 <Transition> 包裹时
|
||||
渲染非元素根节点(slot 透传)导致过渡动画警告/卡死(App.vue <transition name="page">)。 -->
|
||||
<div class="error-boundary-root" :class="{ 'is-error': !!error }">
|
||||
<template v-if="error">
|
||||
<div class="error-boundary-icon">⚠</div>
|
||||
<div class="error-boundary-title">{{ $t('error.title') }}</div>
|
||||
<div class="error-boundary-desc">{{ $t('error.desc') }}</div>
|
||||
<button class="error-boundary-retry" @click="reset">{{ $t('error.retry') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<slot v-else />
|
||||
</template>
|
||||
<slot v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -31,7 +33,12 @@ function reset() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-boundary {
|
||||
/* 根节点:正常态 display:contents 布局透明(slot 内容直接参与父级布局,不破坏页面 flex);
|
||||
error 态切回 flex 容器承载降级 UI。固定为元素根节点,避免 <Transition> 非元素根警告。 */
|
||||
.error-boundary-root {
|
||||
display: contents;
|
||||
}
|
||||
.error-boundary-root.is-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
:class="{
|
||||
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
||||
'ai-conv-item--archived': conv.archived,
|
||||
'ai-conv-item--pinned': conv.pinned,
|
||||
}"
|
||||
@click="onSelectSearchResult(conv.id)"
|
||||
>
|
||||
@@ -52,25 +53,25 @@
|
||||
<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>
|
||||
<div class="ai-conv-item-more">
|
||||
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></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 v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||
<svg width="12" height="12" 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>
|
||||
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, !conv.archived); closeActionsMenu()">
|
||||
<svg width="12" height="12" 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>
|
||||
{{ conv.archived ? t('aiChat.unarchive') : t('aiChat.archive') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
||||
<svg width="12" height="12" 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>
|
||||
{{ t('common.delete') }}
|
||||
</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">
|
||||
@@ -100,7 +101,7 @@
|
||||
v-show="!store.state.foldedGroups[group.key]"
|
||||
:key="conv.id"
|
||||
class="ai-conv-item"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId }"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
@@ -134,25 +135,25 @@
|
||||
<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>
|
||||
<div class="ai-conv-item-more">
|
||||
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></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 v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||
<svg width="12" height="12" 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>
|
||||
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, true); closeActionsMenu()">
|
||||
<svg width="12" height="12" 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>
|
||||
{{ t('aiChat.archive') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
||||
<svg width="12" height="12" 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>
|
||||
{{ t('common.delete') }}
|
||||
</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>
|
||||
@@ -168,7 +169,7 @@
|
||||
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 }"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
@@ -179,25 +180,25 @@
|
||||
<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>
|
||||
<div class="ai-conv-item-more">
|
||||
<button class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></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 v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||
<svg width="12" height="12" 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>
|
||||
{{ conv.pinned ? t('aiChat.unpinConversation') : t('aiChat.pinConversation') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt" @click.stop="store.archiveConversation(conv.id, false); closeActionsMenu()">
|
||||
<svg width="12" height="12" 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>
|
||||
{{ t('aiChat.unarchive') }}
|
||||
</button>
|
||||
<button class="ai-conv-actions-opt ai-conv-actions-opt--danger" @click.stop="emit('confirm-delete', conv.id); closeActionsMenu()">
|
||||
<svg width="12" height="12" 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>
|
||||
{{ t('common.delete') }}
|
||||
</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>
|
||||
@@ -223,17 +224,15 @@
|
||||
* confirmDialog 实例归父所有(useConfirm 每次调用返回独立 ref,非全局单例),
|
||||
* 故删除确认交父处理。父调原 confirmDeleteConversation(id)。
|
||||
* - `confirm-new-conversation`:新建对话(生成中走确认弹层),交父 confirmNewConversation。
|
||||
* - `export-error`(msg):导出失败 toast,父 showToast(分离窗口自管 toast)。
|
||||
* - 父 Ctrl+K 全局快捷键聚焦搜索框:经 defineExpose({ focusSearch }) 供父调,
|
||||
* 父侧 ref 替代原 searchInputRef(零行为变更)。
|
||||
*
|
||||
* 本组件自管的纯侧栏态:titleFlash/重命名/导出菜单/侧栏拖拽/搜索结果/时间分组。
|
||||
* 本组件自管的纯侧栏态: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'
|
||||
|
||||
@@ -242,8 +241,6 @@ const emit = defineEmits<{
|
||||
(e: 'confirm-delete', id: string): void
|
||||
/** 新建对话(生成中需二次确认),父调 confirmNewConversation() */
|
||||
(e: 'confirm-new-conversation'): void
|
||||
/** 导出失败,父 showToast(msg, 'error') */
|
||||
(e: 'export-error', msg: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -304,57 +301,23 @@ function cancelRename() {
|
||||
editingConvId.value = null
|
||||
}
|
||||
|
||||
// ── UX-18: 对话导出 ──
|
||||
const exportOpenId = ref<string | null>(null)
|
||||
const exportingId = ref<string | null>(null)
|
||||
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
|
||||
const actionsOpenId = ref<string | null>(null)
|
||||
|
||||
function toggleExportMenu(id: string) {
|
||||
exportOpenId.value = exportOpenId.value === id ? null : id
|
||||
function toggleActionsMenu(id: string) {
|
||||
actionsOpenId.value = actionsOpenId.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
|
||||
function closeActionsMenu() {
|
||||
actionsOpenId.value = null
|
||||
}
|
||||
|
||||
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
|
||||
// 点击外部关闭更多操作菜单
|
||||
function onActionsOutsideClick(e: MouseEvent) {
|
||||
if (!actionsOpenId.value) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target?.closest('.ai-conv-item-export')) return
|
||||
exportOpenId.value = null
|
||||
if (target?.closest('.ai-conv-item-more')) return
|
||||
actionsOpenId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -444,12 +407,12 @@ function onResizeStart(e: MouseEvent) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// UX-18: 点击外部关闭导出格式菜单(原 AiChat onMounted 同段迁移)
|
||||
document.addEventListener('click', onExportOutsideClick)
|
||||
// 点击外部关闭更多操作菜单
|
||||
document.addEventListener('click', onActionsOutsideClick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onExportOutsideClick)
|
||||
document.removeEventListener('click', onActionsOutsideClick)
|
||||
// UX-2025-16:拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
|
||||
document.removeEventListener('mousemove', onResizeMove)
|
||||
document.removeEventListener('mouseup', onResizeEnd)
|
||||
@@ -684,6 +647,10 @@ body.ai-sidebar-resizing * {
|
||||
.ai-conv-item:hover .ai-conv-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
/* 菜单展开时强制 actions 可见(鼠标移到菜单上 item 不再 hover,防菜单消失) */
|
||||
.ai-conv-item:has(.ai-conv-actions-menu) .ai-conv-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
.ai-conv-item-act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -701,36 +668,27 @@ body.ai-sidebar-resizing * {
|
||||
background: var(--df-sidebar-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-conv-item-act:last-child:hover {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
/* UX-17: 置顶按钮高亮态(已置顶时常驻强调色,不依赖 hover) */
|
||||
.ai-conv-item-act--pinned {
|
||||
color: var(--df-accent, var(--df-text));
|
||||
}
|
||||
/* CR-22-1: 已置顶对话视觉强调(左边线,不依赖 hover 图钉一眼识别)。
|
||||
原方案「图钉常驻」与 hover 浮出 actions 矛盾(父 display:none 压制子),
|
||||
改用 :has 给已置顶 item 左 accent 边线,hover 浮出操作不变。 */
|
||||
.ai-conv-item:has(.ai-conv-item-act--pinned) {
|
||||
/* CR-22-1: 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。
|
||||
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。 */
|
||||
.ai-conv-item--pinned {
|
||||
box-shadow: inset 2px 0 0 var(--df-accent, var(--df-text));
|
||||
}
|
||||
/* ── UX-18: 对话导出按钮 + 格式菜单 ──
|
||||
导出按钮位于归档与删除之间(非 last-child,保持删除按钮 :last-child 红色态不被破坏)。
|
||||
菜单绝对定位浮于 item 下方右侧,窄侧栏优先避免溢出。 */
|
||||
.ai-conv-item-export {
|
||||
/* ── 「更多」操作菜单(置顶/归档/删除)──
|
||||
「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。
|
||||
菜单绝对定位浮于 item 下方右侧,窄侧栏避免溢出。 */
|
||||
.ai-conv-item-more {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-conv-export-menu {
|
||||
.ai-conv-actions-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 2px;
|
||||
z-index: 20;
|
||||
min-width: 84px;
|
||||
min-width: 104px;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -740,20 +698,27 @@ body.ai-sidebar-resizing * {
|
||||
border-radius: var(--df-radius-sm);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.ai-conv-export-opt {
|
||||
.ai-conv-actions-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 4px 8px;
|
||||
padding: 5px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--df-text);
|
||||
cursor: pointer;
|
||||
border-radius: var(--df-radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-conv-export-opt:hover {
|
||||
.ai-conv-actions-opt:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
}
|
||||
.ai-conv-actions-opt--danger:hover {
|
||||
background: var(--df-danger-bg);
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-conv-empty {
|
||||
padding: 16px 8px;
|
||||
text-align: center;
|
||||
|
||||
Reference in New Issue
Block a user