修复: 主菜单点不开 + 对话列表样式/菜单 + 标题卡死/弹回/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:
2026-06-20 01:29:20 +08:00
parent 4746288a70
commit 4e3a11f925
10 changed files with 180 additions and 159 deletions

View File

@@ -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">

View File

@@ -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;

View File

@@ -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;