修复: 主菜单点不开 + 对话列表样式/菜单 + 标题卡死/弹回/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

@@ -345,6 +345,28 @@ impl AiConversationRepo {
.map_err(storage_err)?
}
/// 设置标题(仅改 title,不动 updated_at)
///
/// 区别于 update_field(强制 SET updated_at=now,会把标题生成误判为内容更新,
/// 导致侧栏时间分组/排序跳变)。标题生成是系统后台操作,应保持会话相对时间不变。
pub async fn set_title(&self, id: &str, title: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let title = title.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_conversations SET title = ?1 WHERE id = ?2",
params![title, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 设置置顶标记(仅改 pinned,不动 updated_at) — UX-17
///
/// 同 set_archived:置顶是纯元数据标记,不应改变相对时间。前端排序读 pinned DESC, updated_at DESC。

View File

@@ -1215,6 +1215,30 @@
---
### CR-260619-20 主导航点不开内容(transition + ErrorBoundary 非 element root·commit 7f1242c) — 🟡 待审
- **范围**(2 文件):App.vue(ErrorBoundary 从 transition 内移到外,主+分离两处)+ ErrorBoundary.vue(根固定 div.error-boundary-root + display:contents)
- **根因**:c38cefe ErrorBoundary 接入嵌 transition 内部,slot 透传非元素根 → mode="out-in" 过渡卡死 → 点主菜单 URL 变但页面不切换(AI 面板不走 transition 故正常)
- **维度**:
1. transition 直接子是真实元素根(component :is 路由页面)
2. ErrorBoundary display:contents 正常态布局透明 + error 态 flex 降级
3. 主+分离窗口两处改
- **验证**:独立 grep/read 核验 7f1242c + vue-tsc
- **状态**:🟡 待审
### CR-260619-21 对话标题卡死"新对话"(ensure 跳过占位·commit ecf501d) — 🟡 待审
- **范围**(2 文件):title.rs(:40 ensure 排除"新对话"占位)+ conversation.rs(:139 need_title_regen 含"新对话")
- **根因**:ensure :40 title.is_some() return,"新对话"占位 Some 永远跳过;switch need_title_regen 仅 None,"新对话"不触发 → 双重卡死
- **维度**:
1. ensure :40 排除"新对话"(title Some 且非空非"新对话"才 return)
2. conversation.rs :139 need_title_regen 含"新对话"(switch 触发)
3. 数据印证:6 条"新对话"会话 msg_len 100K+ 有 user(extract 应非 None,证实未生成)
- **验证**:独立 grep/read 核验 ecf501d + cargo check
- **状态**:🟡 待审
---
## 已审归档
> 已审 CR 段迁独立文件: [待审查归档/2026-06.md](./07-项目管理/待审查归档/2026-06.md)

View File

@@ -135,8 +135,10 @@ pub async fn ai_conversation_switch(
let messages_json = record.messages.clone();
let title = record.title.clone();
// B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)
let need_title_regen = record.title.is_none();
// B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)
// 含 "新对话" 占位(Some 但未生成):title.rs ensure :40 同步排除"新对话"占位不跳过,
// 否则 title 落"新对话"(LLM 返空兜底/默认)后 is_some()=true 永远跳过 → 标题卡死。
let need_title_regen = record.title.is_none() || record.title.as_deref() == Some("新对话");
let mut session = state.ai_session.lock().await;
// F-260616-09 B 批4(决策 e 真并发上线):删除 readonly 分支。

View File

@@ -35,12 +35,16 @@ pub(crate) async fn ensure_conversation_title(
) {
let conv_repo = AiConversationRepo::new(db);
// 已有标题(用户改名或已生成)→ 不覆盖
// 已有标题(用户改名或已生成)→ 不覆盖
// 但 "新对话" 是未生成占位(默认/LLM 返空兜底/清理后空回退),视为未生成可重新生成,
// 否则一旦 title 落"新对话"便 is_some()=true 永远跳过 → 标题卡死"新对话"。
if let Ok(Some(rec)) = conv_repo.get_by_id(conv_id).await {
if rec.title.is_some() {
if let Some(t) = &rec.title {
if !t.is_empty() && t != "新对话" {
return;
}
}
}
// 取对话文本(仅 user/assistant跳过 tool 噪音),取前 6 条供 LLM 总结
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源:本函数入参)。
@@ -78,7 +82,7 @@ pub(crate) async fn ensure_conversation_title(
// spawn 未跑完即刷新)时,既不返回 None 也不落库 → 标题长期停留"新对话"。现先落 extract
// 兜底,下方 LLM 生成成功后覆盖;LLM 卡住/失败/超时则保留兜底,无论如何不再"新对话"。
let fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string());
if let Err(e) = conv_repo.update_field(conv_id, "title", &fallback_title).await {
if let Err(e) = conv_repo.set_title(conv_id, &fallback_title).await {
tracing::error!("对话标题兜底落库失败(conv_id={}, title={}): {}", conv_id, fallback_title, e);
}
let _ = app_handle.emit("ai-conversation-changed", ());
@@ -117,11 +121,14 @@ pub(crate) async fn ensure_conversation_title(
}
};
// LLM 生成成功 → 覆盖 extract 兜底
if let Err(e) = conv_repo.update_field(conv_id, "title", &title).await {
// LLM 生成成功且有效(非空非"新对话")→ 覆盖 extract 兜底
// LLM 返空/无效(clean_title 兜底"新对话")→ 保留上方已落的 extract 兜底,不覆盖(防弹回"新对话")。
if !title.is_empty() && title != "新对话" {
if let Err(e) = conv_repo.set_title(conv_id, &title).await {
tracing::error!("对话标题 LLM 落库失败(conv_id={}, title={}): {}", conv_id, title, e);
}
let _ = app_handle.emit("ai-conversation-changed", ());
}
}
/// 后台生成对话标题(不阻塞主流程;失败有 extract_title 兜底)

View File

@@ -1,19 +1,24 @@
<template>
<AppLayout :is-detached="isDetached">
<!-- 主窗口:页面内容(router-view + 页面切换过渡) -->
<!-- ErrorBoundary 包在 transition 外层兜底:transition 的直接子节点须是真实元素根,
若包 ErrorBoundary(slot 透传非元素根)会触发non-element root警告且 mode="out-in"
过渡卡死 路由切换时旧页不卸载/新页不挂载 主菜单点了能选中但打不开内容 -->
<router-view v-slot="{ Component }">
<transition name="page" mode="out-in">
<ErrorBoundary :key="route.path">
<transition name="page" mode="out-in">
<component :is="Component" />
</ErrorBoundary>
</transition>
</ErrorBoundary>
</router-view>
<!-- 分离窗口:纯内容 -->
<template #detached>
<router-view v-slot="{ Component }">
<ErrorBoundary :key="route.path">
<transition name="page" mode="out-in">
<component :is="Component" />
</transition>
</ErrorBoundary>
</router-view>
</template>

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 透传) -->
用法: <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">
<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 />
</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>
<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>
<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>
<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>
<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 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 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">
@@ -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>
<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>
<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>
<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>
<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 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 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>
@@ -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>
<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>
<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>
<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>
<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 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 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>
@@ -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;

View File

@@ -9,6 +9,7 @@ export default {
unarchive: 'Unarchive',
pinConversation: 'Pin',
unpinConversation: 'Unpin',
moreActions: 'More actions',
archived: 'Archived',
archivedCount: 'Archived ({n})',
emptyConversation: 'No conversations',
@@ -154,12 +155,6 @@ export default {
dirAuthDeny: 'Deny',
dirAuthFailed: 'Authorization failed: {msg}',
// ── UX-18: conversation export (sidebar hover, pick format to download) ──
exportConversation: 'Export chat',
exportMarkdown: 'Markdown',
exportJson: 'JSON',
exportTxt: 'Plain text',
exportFailed: 'Export failed: {msg}',
// ── F-15 phase 2: manual context management (clear / compress) ──
// Buttons (Header action area: trash + compress icon)

View File

@@ -9,6 +9,7 @@ export default {
unarchive: '取消归档',
pinConversation: '置顶',
unpinConversation: '取消置顶',
moreActions: '更多操作',
archived: '已归档',
archivedCount: '已归档 ({n})',
emptyConversation: '暂无对话',
@@ -155,12 +156,6 @@ export default {
dirAuthDeny: '拒绝',
dirAuthFailed: '授权失败:{msg}',
// ── UX-18:对话导出(侧栏 hover 浮出,选格式下载) ──
exportConversation: '导出对话',
exportMarkdown: 'Markdown',
exportJson: 'JSON',
exportTxt: '纯文本',
exportFailed: '导出失败:{msg}',
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// 按钮(Header 操作区:垃圾桶 + 压缩图标)