修复: 主菜单点不开 + 对话列表样式/菜单 + 标题卡死/弹回/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:
@@ -345,6 +345,28 @@ impl AiConversationRepo {
|
|||||||
.map_err(storage_err)?
|
.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
|
/// 设置置顶标记(仅改 pinned,不动 updated_at) — UX-17
|
||||||
///
|
///
|
||||||
/// 同 set_archived:置顶是纯元数据标记,不应改变相对时间。前端排序读 pinned DESC, updated_at DESC。
|
/// 同 set_archived:置顶是纯元数据标记,不应改变相对时间。前端排序读 pinned DESC, updated_at DESC。
|
||||||
|
|||||||
24
docs/待审查.md
24
docs/待审查.md
@@ -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)
|
> 已审 CR 段迁独立文件: [待审查归档/2026-06.md](./07-项目管理/待审查归档/2026-06.md)
|
||||||
|
|||||||
@@ -135,8 +135,10 @@ pub async fn ai_conversation_switch(
|
|||||||
|
|
||||||
let messages_json = record.messages.clone();
|
let messages_json = record.messages.clone();
|
||||||
let title = record.title.clone();
|
let title = record.title.clone();
|
||||||
// B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)
|
// B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)。
|
||||||
let need_title_regen = record.title.is_none();
|
// 含 "新对话" 占位(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;
|
let mut session = state.ai_session.lock().await;
|
||||||
// F-260616-09 B 批4(决策 e 真并发上线):删除 readonly 分支。
|
// F-260616-09 B 批4(决策 e 真并发上线):删除 readonly 分支。
|
||||||
|
|||||||
@@ -35,10 +35,14 @@ pub(crate) async fn ensure_conversation_title(
|
|||||||
) {
|
) {
|
||||||
let conv_repo = AiConversationRepo::new(db);
|
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 let Ok(Some(rec)) = conv_repo.get_by_id(conv_id).await {
|
||||||
if rec.title.is_some() {
|
if let Some(t) = &rec.title {
|
||||||
return;
|
if !t.is_empty() && t != "新对话" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +82,7 @@ pub(crate) async fn ensure_conversation_title(
|
|||||||
// spawn 未跑完即刷新)时,既不返回 None 也不落库 → 标题长期停留"新对话"。现先落 extract
|
// spawn 未跑完即刷新)时,既不返回 None 也不落库 → 标题长期停留"新对话"。现先落 extract
|
||||||
// 兜底,下方 LLM 生成成功后覆盖;LLM 卡住/失败/超时则保留兜底,无论如何不再"新对话"。
|
// 兜底,下方 LLM 生成成功后覆盖;LLM 卡住/失败/超时则保留兜底,无论如何不再"新对话"。
|
||||||
let fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string());
|
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);
|
tracing::error!("对话标题兜底落库失败(conv_id={}, title={}): {}", conv_id, fallback_title, e);
|
||||||
}
|
}
|
||||||
let _ = app_handle.emit("ai-conversation-changed", ());
|
let _ = app_handle.emit("ai-conversation-changed", ());
|
||||||
@@ -117,11 +121,14 @@ pub(crate) async fn ensure_conversation_title(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// LLM 生成成功 → 覆盖 extract 兜底
|
// LLM 生成成功且有效(非空非"新对话")→ 覆盖 extract 兜底。
|
||||||
if let Err(e) = conv_repo.update_field(conv_id, "title", &title).await {
|
// LLM 返空/无效(clean_title 兜底"新对话")→ 保留上方已落的 extract 兜底,不覆盖(防弹回"新对话")。
|
||||||
tracing::error!("对话标题 LLM 落库失败(conv_id={}, title={}): {}", conv_id, title, e);
|
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", ());
|
||||||
}
|
}
|
||||||
let _ = app_handle.emit("ai-conversation-changed", ());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 后台生成对话标题(不阻塞主流程;失败有 extract_title 兜底)
|
/// 后台生成对话标题(不阻塞主流程;失败有 extract_title 兜底)
|
||||||
|
|||||||
15
src/App.vue
15
src/App.vue
@@ -1,19 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout :is-detached="isDetached">
|
<AppLayout :is-detached="isDetached">
|
||||||
<!-- 主窗口:页面内容(router-view + 页面切换过渡) -->
|
<!-- 主窗口:页面内容(router-view + 页面切换过渡) -->
|
||||||
|
<!-- ErrorBoundary 包在 transition 外层兜底:transition 的直接子节点须是真实元素根,
|
||||||
|
若包 ErrorBoundary(slot 透传非元素根)会触发「non-element root」警告且 mode="out-in"
|
||||||
|
过渡卡死 → 路由切换时旧页不卸载/新页不挂载 → 主菜单点了能选中但打不开内容。 -->
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="page" mode="out-in">
|
<ErrorBoundary :key="route.path">
|
||||||
<ErrorBoundary :key="route.path">
|
<transition name="page" mode="out-in">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</ErrorBoundary>
|
</transition>
|
||||||
</transition>
|
</ErrorBoundary>
|
||||||
</router-view>
|
</router-view>
|
||||||
|
|
||||||
<!-- 分离窗口:纯内容 -->
|
<!-- 分离窗口:纯内容 -->
|
||||||
<template #detached>
|
<template #detached>
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<ErrorBoundary :key="route.path">
|
<ErrorBoundary :key="route.path">
|
||||||
<component :is="Component" />
|
<transition name="page" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</transition>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</router-view>
|
</router-view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
ref="convSidebarRef"
|
ref="convSidebarRef"
|
||||||
@confirm-delete="confirmDeleteConversation"
|
@confirm-delete="confirmDeleteConversation"
|
||||||
@confirm-new-conversation="confirmNewConversation"
|
@confirm-new-conversation="confirmNewConversation"
|
||||||
@export-error="(msg: string) => showToast(msg, 'error')"
|
|
||||||
/>
|
/>
|
||||||
<!-- ═══ 聊天区域 ═══ -->
|
<!-- ═══ 聊天区域 ═══ -->
|
||||||
<div class="ai-chat-area">
|
<div class="ai-chat-area">
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 全局错误兜底:子树渲染/生命周期抛错时显示降级 UI,防止白屏。
|
<!-- 全局错误兜底:子树渲染/生命周期抛错时显示降级 UI,防止白屏。
|
||||||
用法:用 <ErrorBoundary> 包裹可能出错的子组件树(slot 透传)。 -->
|
用法:用 <ErrorBoundary> 包裹可能出错的子组件树(slot 透传)。
|
||||||
<template v-if="error">
|
根节点固定为单个 <div>(display:contents 布局透明),避免被 <Transition> 包裹时
|
||||||
<div class="error-boundary">
|
渲染非元素根节点(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-icon">⚠</div>
|
||||||
<div class="error-boundary-title">{{ $t('error.title') }}</div>
|
<div class="error-boundary-title">{{ $t('error.title') }}</div>
|
||||||
<div class="error-boundary-desc">{{ $t('error.desc') }}</div>
|
<div class="error-boundary-desc">{{ $t('error.desc') }}</div>
|
||||||
<button class="error-boundary-retry" @click="reset">{{ $t('error.retry') }}</button>
|
<button class="error-boundary-retry" @click="reset">{{ $t('error.retry') }}</button>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
<slot v-else />
|
||||||
<slot v-else />
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -31,7 +33,12 @@ function reset() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.error-boundary {
|
/* 根节点:正常态 display:contents 布局透明(slot 内容直接参与父级布局,不破坏页面 flex);
|
||||||
|
error 态切回 flex 容器承载降级 UI。固定为元素根节点,避免 <Transition> 非元素根警告。 */
|
||||||
|
.error-boundary-root {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.error-boundary-root.is-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
:class="{
|
:class="{
|
||||||
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
||||||
'ai-conv-item--archived': conv.archived,
|
'ai-conv-item--archived': conv.archived,
|
||||||
|
'ai-conv-item--pinned': conv.pinned,
|
||||||
}"
|
}"
|
||||||
@click="onSelectSearchResult(conv.id)"
|
@click="onSelectSearchResult(conv.id)"
|
||||||
>
|
>
|
||||||
@@ -52,25 +53,25 @@
|
|||||||
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-conv-item-actions">
|
<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')">
|
<div class="ai-conv-item-more">
|
||||||
<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 class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
|
||||||
</button>
|
<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 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>
|
</button>
|
||||||
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ t('aiChat.exportMarkdown') }}</button>
|
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ t('aiChat.exportJson') }}</button>
|
<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>
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ t('aiChat.exportTxt') }}</button>
|
{{ 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>
|
||||||
</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>
|
</div>
|
||||||
<div v-if="searchResults.length === 0" class="ai-conv-empty">
|
<div v-if="searchResults.length === 0" class="ai-conv-empty">
|
||||||
@@ -100,7 +101,7 @@
|
|||||||
v-show="!store.state.foldedGroups[group.key]"
|
v-show="!store.state.foldedGroups[group.key]"
|
||||||
:key="conv.id"
|
:key="conv.id"
|
||||||
class="ai-conv-item"
|
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)"
|
@click="store.switchConversation(conv.id)"
|
||||||
>
|
>
|
||||||
<div class="ai-conv-item-main">
|
<div class="ai-conv-item-main">
|
||||||
@@ -134,25 +135,25 @@
|
|||||||
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-conv-item-actions">
|
<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')">
|
<div class="ai-conv-item-more">
|
||||||
<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 class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
|
||||||
</button>
|
<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 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>
|
</button>
|
||||||
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ t('aiChat.exportMarkdown') }}</button>
|
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ t('aiChat.exportJson') }}</button>
|
<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>
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ t('aiChat.exportTxt') }}</button>
|
{{ 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>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -168,7 +169,7 @@
|
|||||||
v-show="!store.state.archivedCollapsed"
|
v-show="!store.state.archivedCollapsed"
|
||||||
:key="'a-' + conv.id"
|
:key="'a-' + conv.id"
|
||||||
class="ai-conv-item ai-conv-item--archived"
|
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)"
|
@click="store.switchConversation(conv.id)"
|
||||||
>
|
>
|
||||||
<div class="ai-conv-item-main">
|
<div class="ai-conv-item-main">
|
||||||
@@ -179,25 +180,25 @@
|
|||||||
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-conv-item-actions">
|
<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')">
|
<div class="ai-conv-item-more">
|
||||||
<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 class="ai-conv-item-act" @click.stop="toggleActionsMenu(conv.id)" :title="t('aiChat.moreActions')">
|
||||||
</button>
|
<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 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>
|
</button>
|
||||||
<div v-if="exportOpenId === conv.id" class="ai-conv-export-menu">
|
<div v-if="actionsOpenId === conv.id" class="ai-conv-actions-menu">
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'markdown')">{{ t('aiChat.exportMarkdown') }}</button>
|
<button class="ai-conv-actions-opt" @click.stop="store.setPinnedConversation(conv.id, !conv.pinned); closeActionsMenu()">
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'json')">{{ t('aiChat.exportJson') }}</button>
|
<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>
|
||||||
<button class="ai-conv-export-opt" @click.stop="exportConversation(conv, 'txt')">{{ t('aiChat.exportTxt') }}</button>
|
{{ 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>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -223,17 +224,15 @@
|
|||||||
* confirmDialog 实例归父所有(useConfirm 每次调用返回独立 ref,非全局单例),
|
* confirmDialog 实例归父所有(useConfirm 每次调用返回独立 ref,非全局单例),
|
||||||
* 故删除确认交父处理。父调原 confirmDeleteConversation(id)。
|
* 故删除确认交父处理。父调原 confirmDeleteConversation(id)。
|
||||||
* - `confirm-new-conversation`:新建对话(生成中走确认弹层),交父 confirmNewConversation。
|
* - `confirm-new-conversation`:新建对话(生成中走确认弹层),交父 confirmNewConversation。
|
||||||
* - `export-error`(msg):导出失败 toast,父 showToast(分离窗口自管 toast)。
|
|
||||||
* - 父 Ctrl+K 全局快捷键聚焦搜索框:经 defineExpose({ focusSearch }) 供父调,
|
* - 父 Ctrl+K 全局快捷键聚焦搜索框:经 defineExpose({ focusSearch }) 供父调,
|
||||||
* 父侧 ref 替代原 searchInputRef(零行为变更)。
|
* 父侧 ref 替代原 searchInputRef(零行为变更)。
|
||||||
*
|
*
|
||||||
* 本组件自管的纯侧栏态:titleFlash/重命名/导出菜单/侧栏拖拽/搜索结果/时间分组。
|
* 本组件自管的纯侧栏态:titleFlash/重命名/更多操作菜单/侧栏拖拽/搜索结果/时间分组。
|
||||||
* 样式:零 scoped style(沿用 AiChat.vue 全局/父级样式,样式后续批下沉)。
|
* 样式:零 scoped style(沿用 AiChat.vue 全局/父级样式,样式后续批下沉)。
|
||||||
*/
|
*/
|
||||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { aiApi } from '../../api'
|
|
||||||
import { formatRelativeZh } from '../../utils/time'
|
import { formatRelativeZh } from '../../utils/time'
|
||||||
import type { AiConversationSummary } from '../../api/types'
|
import type { AiConversationSummary } from '../../api/types'
|
||||||
|
|
||||||
@@ -242,8 +241,6 @@ const emit = defineEmits<{
|
|||||||
(e: 'confirm-delete', id: string): void
|
(e: 'confirm-delete', id: string): void
|
||||||
/** 新建对话(生成中需二次确认),父调 confirmNewConversation() */
|
/** 新建对话(生成中需二次确认),父调 confirmNewConversation() */
|
||||||
(e: 'confirm-new-conversation'): void
|
(e: 'confirm-new-conversation'): void
|
||||||
/** 导出失败,父 showToast(msg, 'error') */
|
|
||||||
(e: 'export-error', msg: string): void
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -304,57 +301,23 @@ function cancelRename() {
|
|||||||
editingConvId.value = null
|
editingConvId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UX-18: 对话导出 ──
|
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
|
||||||
const exportOpenId = ref<string | null>(null)
|
const actionsOpenId = ref<string | null>(null)
|
||||||
const exportingId = ref<string | null>(null)
|
|
||||||
|
|
||||||
function toggleExportMenu(id: string) {
|
function toggleActionsMenu(id: string) {
|
||||||
exportOpenId.value = exportOpenId.value === id ? null : id
|
actionsOpenId.value = actionsOpenId.value === id ? null : id
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeFilename(title: string | null | undefined, fallback: string): string {
|
function closeActionsMenu() {
|
||||||
const raw = (title || '').trim()
|
actionsOpenId.value = null
|
||||||
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
|
function onActionsOutsideClick(e: MouseEvent) {
|
||||||
if (exportingId.value) return
|
if (!actionsOpenId.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
|
const target = e.target as HTMLElement | null
|
||||||
if (target?.closest('.ai-conv-item-export')) return
|
if (target?.closest('.ai-conv-item-more')) return
|
||||||
exportOpenId.value = null
|
actionsOpenId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -444,12 +407,12 @@ function onResizeStart(e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// UX-18: 点击外部关闭导出格式菜单(原 AiChat onMounted 同段迁移)
|
// 点击外部关闭更多操作菜单
|
||||||
document.addEventListener('click', onExportOutsideClick)
|
document.addEventListener('click', onActionsOutsideClick)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', onExportOutsideClick)
|
document.removeEventListener('click', onActionsOutsideClick)
|
||||||
// UX-2025-16:拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
|
// UX-2025-16:拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
|
||||||
document.removeEventListener('mousemove', onResizeMove)
|
document.removeEventListener('mousemove', onResizeMove)
|
||||||
document.removeEventListener('mouseup', onResizeEnd)
|
document.removeEventListener('mouseup', onResizeEnd)
|
||||||
@@ -684,6 +647,10 @@ body.ai-sidebar-resizing * {
|
|||||||
.ai-conv-item:hover .ai-conv-item-actions {
|
.ai-conv-item:hover .ai-conv-item-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
/* 菜单展开时强制 actions 可见(鼠标移到菜单上 item 不再 hover,防菜单消失) */
|
||||||
|
.ai-conv-item:has(.ai-conv-actions-menu) .ai-conv-item-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
.ai-conv-item-act {
|
.ai-conv-item-act {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -701,36 +668,27 @@ body.ai-sidebar-resizing * {
|
|||||||
background: var(--df-sidebar-hover);
|
background: var(--df-sidebar-hover);
|
||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
}
|
}
|
||||||
.ai-conv-item-act:last-child:hover {
|
/* CR-22-1: 已置顶对话视觉强调(左边线,不依赖菜单展开,一眼识别)。
|
||||||
background: var(--df-danger-bg);
|
pinned 类挂在 .ai-conv-item 上(操作收进更多菜单后,按钮不再常驻,改 item 级类)。 */
|
||||||
color: var(--df-danger);
|
.ai-conv-item--pinned {
|
||||||
}
|
|
||||||
/* 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) {
|
|
||||||
box-shadow: inset 2px 0 0 var(--df-accent, var(--df-text));
|
box-shadow: inset 2px 0 0 var(--df-accent, var(--df-text));
|
||||||
}
|
}
|
||||||
/* ── UX-18: 对话导出按钮 + 格式菜单 ──
|
/* ── 「更多」操作菜单(置顶/归档/删除)──
|
||||||
导出按钮位于归档与删除之间(非 last-child,保持删除按钮 :last-child 红色态不被破坏)。
|
「...」按钮 hover 时随 actions 浮出(同原 4 按钮交互),点击展开下拉。
|
||||||
菜单绝对定位浮于 item 下方右侧,窄侧栏优先避免溢出。 */
|
菜单绝对定位浮于 item 下方右侧,窄侧栏避免溢出。 */
|
||||||
.ai-conv-item-export {
|
.ai-conv-item-more {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ai-conv-export-menu {
|
.ai-conv-actions-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
min-width: 84px;
|
min-width: 104px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -740,20 +698,27 @@ body.ai-sidebar-resizing * {
|
|||||||
border-radius: var(--df-radius-sm);
|
border-radius: var(--df-radius-sm);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
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;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 4px 8px;
|
padding: 5px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--df-radius-sm);
|
border-radius: var(--df-radius-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ai-conv-export-opt:hover {
|
.ai-conv-actions-opt:hover {
|
||||||
background: var(--df-sidebar-hover);
|
background: var(--df-sidebar-hover);
|
||||||
}
|
}
|
||||||
|
.ai-conv-actions-opt--danger:hover {
|
||||||
|
background: var(--df-danger-bg);
|
||||||
|
color: var(--df-danger);
|
||||||
|
}
|
||||||
.ai-conv-empty {
|
.ai-conv-empty {
|
||||||
padding: 16px 8px;
|
padding: 16px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default {
|
|||||||
unarchive: 'Unarchive',
|
unarchive: 'Unarchive',
|
||||||
pinConversation: 'Pin',
|
pinConversation: 'Pin',
|
||||||
unpinConversation: 'Unpin',
|
unpinConversation: 'Unpin',
|
||||||
|
moreActions: 'More actions',
|
||||||
archived: 'Archived',
|
archived: 'Archived',
|
||||||
archivedCount: 'Archived ({n})',
|
archivedCount: 'Archived ({n})',
|
||||||
emptyConversation: 'No conversations',
|
emptyConversation: 'No conversations',
|
||||||
@@ -154,12 +155,6 @@ export default {
|
|||||||
dirAuthDeny: 'Deny',
|
dirAuthDeny: 'Deny',
|
||||||
dirAuthFailed: 'Authorization failed: {msg}',
|
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) ──
|
// ── F-15 phase 2: manual context management (clear / compress) ──
|
||||||
// Buttons (Header action area: trash + compress icon)
|
// Buttons (Header action area: trash + compress icon)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default {
|
|||||||
unarchive: '取消归档',
|
unarchive: '取消归档',
|
||||||
pinConversation: '置顶',
|
pinConversation: '置顶',
|
||||||
unpinConversation: '取消置顶',
|
unpinConversation: '取消置顶',
|
||||||
|
moreActions: '更多操作',
|
||||||
archived: '已归档',
|
archived: '已归档',
|
||||||
archivedCount: '已归档 ({n})',
|
archivedCount: '已归档 ({n})',
|
||||||
emptyConversation: '暂无对话',
|
emptyConversation: '暂无对话',
|
||||||
@@ -155,12 +156,6 @@ export default {
|
|||||||
dirAuthDeny: '拒绝',
|
dirAuthDeny: '拒绝',
|
||||||
dirAuthFailed: '授权失败:{msg}',
|
dirAuthFailed: '授权失败:{msg}',
|
||||||
|
|
||||||
// ── UX-18:对话导出(侧栏 hover 浮出,选格式下载) ──
|
|
||||||
exportConversation: '导出对话',
|
|
||||||
exportMarkdown: 'Markdown',
|
|
||||||
exportJson: 'JSON',
|
|
||||||
exportTxt: '纯文本',
|
|
||||||
exportFailed: '导出失败:{msg}',
|
|
||||||
|
|
||||||
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
|
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
|
||||||
// 按钮(Header 操作区:垃圾桶 + 压缩图标)
|
// 按钮(Header 操作区:垃圾桶 + 压缩图标)
|
||||||
|
|||||||
Reference in New Issue
Block a user