Files
DevFlow/src/components/ai/TopBar.vue
T
lxy 62776a30f1 修复: ai-chat 交互体验批(草稿备份/图片放大/目标完成态/淘汰边界条)
- 输入侧:编辑消息不再静默销毁草稿(备份/回填)、补全浮层滚随、贴图 loading 态+并发上限复核、mention 失焦收口、富文本图文同粘、上传失败提示
- 消息流:图片点击 lightbox 放大、复制钮 focus 可见、复制为图片降级文案据实、JSON 块恢复可选、bucketSig 改哈希防长会话卡顿、user 长消息折叠、工具卡流式滚尾
- 会话框架:后台任务面板入点外/Esc 收起编排、恢复任务行级 busy+失败 toast、菜单底部翻转、搜索态统一清、归档禁改名对齐、listbox 语义+方向键、状态点 title、窄侧栏容器查询
- 跨端:删除目标不再抹其余目标完成态(前后端 GoalEntry 全量传递+失败回滚)
- 边界条:MESSAGE_CAP 滚动淘汰不再静默,头部一键补齐完整会话,历史定位文案据实区分
2026-10-25 22:32:49 +08:00

412 lines
16 KiB
Vue

<template>
<!-- ═══ TopBar 编排层(c1c 拆分:1697 → 子件目录 top-bar/,零行为变更)═══
区块自治:HeaderLeft/ProviderCenter/MoreMenu/WindowControls(顶部四块)+
TaskProgressBadge/RunningTasks/Goals/History/Summary/Context(底部六面板)+
ProviderSwitchBar(切换临时 bar),样式与区块逻辑随子件迁移。
本层只留跨区块编排:五面板互斥(togglePanel)/点外关闭/ESC/scrollToMessage/
msgDerived 单遍派生(三路共享)/provider 切换 bar 显隐。
外部契约不变:4 props + 16 emits 原样透传 AiChat(含 new-conversation /
clear-chat 两个历史遗留未触发 emit,D-A 波已报备,原样保留)。 ═══ -->
<div class="ai-header">
<HeaderLeft
:pending-approval-count="pendingApprovalCount"
@toggle-sidebar="emit('toggle-sidebar')"
@scroll-to-pending="emit('scroll-to-pending')"
/>
<ProviderCenter
:active-provider-name="activeProviderName"
@provider-switched="showProviderSwitched"
/>
<div class="ai-header-actions">
<MoreMenu
:has-active-messages="hasActiveMessages"
@clear-context="emit('clear-context')"
@compress-context="emit('compress-context')"
@show-shortcuts="emit('show-shortcuts')"
/>
<WindowControls
:detached="detached"
:always-on-top="alwaysOnTop"
@toggle-maximize="emit('toggle-maximize')"
@detach="emit('detach')"
@close-panel="emit('close-panel')"
@reattach="emit('reattach')"
@dock="emit('dock')"
@toggle-always-on-top="emit('toggle-always-on-top')"
@close-detached="emit('close-detached')"
/>
</div>
</div>
<!-- 底部工具区:标题 + 目标 + 历史消息 + 上下文摘要 -->
<div class="ai-bottom-tools" ref="goalsRef">
<span v-if="currentConvTitle" class="ai-bottom-title">{{ currentConvTitle }}</span>
<div class="ai-bottom-tools-right">
<TaskProgressBadge />
<RunningTasksPanel
:expanded="runningTasksExpanded"
@toggle="togglePanel('running')"
@close="runningTasksExpanded = false"
/>
<GoalsPanel
:expanded="goalsExpanded"
@toggle="togglePanel('goals')"
@close="goalsExpanded = false"
/>
<HistoryPanel
:items="historyMsgs"
:expanded="historyExpanded"
:locate-miss="locateMissHint"
@toggle="togglePanel('history')"
@navigate="scrollToMessage"
/>
<SummaryPanel
:items="summaryMsgs"
:expanded="summaryExpanded"
:locate-miss="locateMissHint"
@toggle="togglePanel('summary')"
@navigate="scrollToMessage"
/>
<ContextPanel
:info="contextInfo"
:expanded="contextExpanded"
@toggle="togglePanel('context')"
/>
</div>
</div>
<ProviderSwitchBar
:visible="providerBarVisible"
:provider-name="activeProviderName"
/>
</template>
<script setup lang="ts">
// TopBar 编排层(第三批抽离自 AiChat.vue,c1c 二次拆分至 top-bar/ 子件目录)
// 零行为变更。provider/model 等区块逻辑在各子件内直读 store(单例共享),动作 emit 回本层,
// 本层透传 AiChat(父持有 clear/compress/confirm 逻辑 + 单一 toast 源)。
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import HeaderLeft from './top-bar/HeaderLeft.vue'
import ProviderCenter from './top-bar/ProviderCenter.vue'
import MoreMenu from './top-bar/MoreMenu.vue'
import WindowControls from './top-bar/WindowControls.vue'
import TaskProgressBadge from './top-bar/TaskProgressBadge.vue'
import RunningTasksPanel from './top-bar/RunningTasksPanel.vue'
import GoalsPanel from './top-bar/GoalsPanel.vue'
import HistoryPanel from './top-bar/HistoryPanel.vue'
import SummaryPanel from './top-bar/SummaryPanel.vue'
import ContextPanel from './top-bar/ContextPanel.vue'
import ProviderSwitchBar from './top-bar/ProviderSwitchBar.vue'
import type { HistoryItem, SummaryItem, ContextInfo } from './top-bar/types'
defineProps<{
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
detached?: boolean
/** 有活跃消息(清空/压缩按钮启用条件;父 computed) */
hasActiveMessages: boolean
/** 待审批数(审批角标显隐 + 计数;父 computed) */
pendingApprovalCount: number
/** 分离窗口置顶态(父持有,经 prop 透传按钮 active 态) */
alwaysOnTop: boolean
}>()
const emit = defineEmits<{
/** 折叠/展开会话侧栏(store.toggleSidebar,父转发保持 store 单一调用源) */
(e: 'toggle-sidebar'): void
/** 新建会话(父 confirmNewConversation,F-09 决策e 直接新建无中断确认) */
(e: 'new-conversation'): void
/** 清空上下文(归档保留;父 handleClearContext) */
(e: 'clear-context'): void
/** 压缩上下文(LLM 摘要;父 handleCompressContext) */
(e: 'compress-context'): void
/** 清空对话(真删 DB;父 confirmClearChat 带二次确认) */
(e: 'clear-chat'): void
/** 点击审批角标滚动到首个待审批(父 scrollToFirstPending) */
(e: 'scroll-to-pending'): void
/** 最大化/还原(store.toggleMaximize) */
(e: 'toggle-maximize'): void
/** 分离窗口(store.detachPanel) */
(e: 'detach'): void
/** 关闭面板(store.togglePanel) */
(e: 'close-panel'): void
/** 吸附/取消吸附(store.dockDetached) */
(e: 'dock'): void
/** 置顶切换(父 toggleAlwaysOnTop 调 Tauri setAlwaysOnTop) */
(e: 'toggle-always-on-top'): void
/** V11b 收回分离窗回主面板(父 emitReattachToMain 广播,主窗 reattachPanel 执行) */
(e: 'reattach'): void
/** 关闭分离窗口(store.closeDetachedWindow) */
(e: 'close-detached'): void
/** I12 展示快捷键速查面板(父 shortcutsVisible=true,面板体在父 Teleport 到 body) */
(e: 'show-shortcuts'): void
/** Provider 循环切换后回传 model 名(父 showToast 弹切换提示,保持单一 toast 源) */
(e: 'provider-switched', modelName: string): void
}>()
const store = useAiStore()
const { t } = useI18n()
// ── 当前生效 provider 名(ProviderCenter header 显示 + ProviderSwitchBar 提示共用一份)──
const activeProviderName = computed(() => {
const active = store.state.providers.find(p => p.id === store.state.activeProvider)
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
})
// ── 当前对话标题 ──
const currentConvTitle = computed(() => {
const id = store.state.activeConversationId
if (!id) return ''
const conv = store.state.conversations.find(c => c.id === id)
return conv?.title || ''
})
// ── 📋 历史/摘要/项目名单遍派生(历史面板 + 摘要面板 + L3 上下文三路共享) ──
/** 时间显示:今日 HH:mm / 昨天 HH:mm / MM-DD HH:mm */
function fmtTime(ts: number): string {
const d = new Date(ts)
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`
// 同一天 → 只显示时间
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) {
return hm
}
// 昨天
const yest = new Date(now)
yest.setDate(yest.getDate() - 1)
if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) {
return `${t('aiChat.yesterday')} ${hm}`
}
// 更早
return `${pad(d.getMonth()+1)}-${pad(d.getDate())} ${hm}`
}
const MAX_HISTORY = 20
const MAX_CHARS = 80
// 单次遍历 messages,一次产出「历史(user 消息)/摘要(system 消息)/项目名」三路派生,
// 替代原先三处 computed 各自全量扫描(historyMsgs/summaryMsgs/contextInfo)。
// 三路的筛选条件、收集顺序、截断与反转语义与拆分前逐项一致。
const msgDerived = computed(() => {
const history: HistoryItem[] = []
const summaries: SummaryItem[] = []
const projectLabels = new Set<string>()
for (const m of store.state.messages) {
if (m.role === 'user') {
// 项目名收集只认 user 的 mentionSpans(不设 content/timestamp 门槛),
// 与原 contextInfo 的独立遍历同条件,故与下面的历史筛选互不干扰。
if (m.mentionSpans?.length) {
for (const sp of m.mentionSpans) {
if (sp.kind === 'project' && sp.label) projectLabels.add(sp.label)
}
}
if (m.content && m.timestamp) {
history.push({
id: m.id,
text: m.content.length > MAX_CHARS ? m.content.slice(0, MAX_CHARS) + '…' : m.content,
time: fmtTime(m.timestamp),
num: history.length + 1,
})
}
} else if (m.role === 'system' && m.content) {
summaries.push({
id: m.id,
text: m.content.length > 120 ? m.content.slice(0, 120) + '…' : m.content,
})
}
}
return {
history: history.slice(-MAX_HISTORY).reverse(),
summaries,
projectNames: Array.from(projectLabels).join(', '),
}
})
const historyMsgs = computed<HistoryItem[]>(() => msgDerived.value.history)
const summaryMsgs = computed<SummaryItem[]>(() => msgDerived.value.summaries)
// ── 五面板互斥编排(goals/history/summary/context/running)──
const goalsExpanded = ref(false)
const historyExpanded = ref(false)
const summaryExpanded = ref(false)
const contextExpanded = ref(false)
const runningTasksExpanded = ref(false)
// F6-1(走查-信息UX 面六):目标消息在折叠段(已归档/已压缩)内时,折叠态不渲染
// [data-msg-id] 元素,querySelector 必空。原实现先收面板再查 DOM,未命中即静默无反应;
// 现改为面板保持展开 + 行内提示引导展开分隔条。展开折叠段的状态在 MessageList 内持有,
// 本组件不可达,提示是最小闭环(不跨组件撬状态)。
const locateMissHint = ref('')
let locateMissTimer: ReturnType<typeof setTimeout> | null = null
function showLocateMiss(msgId?: string): void {
// 走查批:miss 原因据实区分——消息不在当前数组(已被 MESSAGE_CAP 滚动淘汰)给淘汰文案,
// 引导点消息流头部边界条补齐;在数组但 DOM 未命中(折叠段未展开)才提示展开分隔条。
const prunedOut =
msgId != null &&
store.state.messagesPruned > 0 &&
!store.state.messages.some(m => m.id === msgId)
locateMissHint.value = prunedOut
? t('aiChat.historyLocatePruned')
: t('aiChat.historyLocateInSegment')
if (locateMissTimer) clearTimeout(locateMissTimer)
locateMissTimer = setTimeout(() => {
locateMissHint.value = ''
locateMissTimer = null
}, 5000)
}
/** 点击历史/摘要消息项 → 滚动定位到对应的消息气泡 */
function scrollToMessage(msgId: string) {
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位。
// msgId 由后端生成,可能含 CSS 选择器元字符(: . [ ] / 空格 等),
// 模板拼 selector 会被选择器解析器当伪类/类名异常 → querySelector 抛错或返回 null。
// 用 CSS.escape 转义 msgId,确保任意字符串安全嵌入属性选择器。
// 旧浏览器无 CSS.escape 的兜底:encodeURIComponent + attr*= 模糊匹配(非精确,降级路径)。
let el: Element | null = null
const escaped = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
? CSS.escape(msgId)
: encodeURIComponent(msgId)
try {
el = document.querySelector(`[data-msg-id="${escaped}"]`)
} catch {
// 转义后仍异常(理论不会)→ 退化为遍历查找
const all = document.querySelectorAll('[data-msg-id]')
el = Array.from(all).find(n => n.getAttribute('data-msg-id') === msgId) ?? null
}
if (el) {
locateMissHint.value = ''
historyExpanded.value = false
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
// 未命中:不收面板,提示替代静默(带 msgId 供淘汰/折叠段原因区分)
showLocateMiss(msgId)
}
/** 目标/历史下拉容器 DOM 引用(用于点击外部检测) */
const goalsRef = ref<HTMLElement | null>(null)
function onBottomToolsClickOutside(e: MouseEvent) {
if (!goalsRef.value) return
if (goalsExpanded.value && !goalsRef.value.contains(e.target as Node)) {
goalsExpanded.value = false
}
if (historyExpanded.value && !goalsRef.value.contains(e.target as Node)) {
historyExpanded.value = false
}
if (summaryExpanded.value && !goalsRef.value.contains(e.target as Node)) {
summaryExpanded.value = false
}
if (contextExpanded.value && !goalsRef.value.contains(e.target as Node)) {
contextExpanded.value = false
}
if (runningTasksExpanded.value && !goalsRef.value.contains(e.target as Node)) {
runningTasksExpanded.value = false
}
}
/** 弹出面板互斥:goals/history/summary/context/running 五个 badge 面板,点一个收起其他,再点当前切换。 */
function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'running') {
locateMissHint.value = ''
const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded, running: runningTasksExpanded }
const willOpen = !refs[panel].value
goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = runningTasksExpanded.value = false
refs[panel].value = willOpen
// B3(running 面板展开拉可恢复清单)随 RunningTasksPanel 迁移,由其 expanded watch 承接
}
/** 按 Escape 关闭所有下拉 */
function onBottomToolsKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
goalsExpanded.value = false
historyExpanded.value = false
summaryExpanded.value = false
contextExpanded.value = false
runningTasksExpanded.value = false
}
}
onMounted(() => {
document.addEventListener('click', onBottomToolsClickOutside)
document.addEventListener('keydown', onBottomToolsKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onBottomToolsClickOutside)
document.removeEventListener('keydown', onBottomToolsKeydown)
if (locateMissTimer) { clearTimeout(locateMissTimer); locateMissTimer = null }
})
// ── L3 完整上下文(ContextPanel 数据派生)──
const contextInfo = computed<ContextInfo>(() => {
const convId = store.state.activeConversationId
const conv = convId ? store.state.conversations.find(c => c.id === convId) : undefined
return {
goals: conv?.pinned_goals ?? [],
summaryCount: msgDerived.value.summaries.length,
osInfo: 'Windows 11 / PowerShell',
projectNames: msgDerived.value.projectNames || t('aiChat.none'),
}
})
// ── Provider 切换临时 bar ──
// 切换后展示当前 model 名小字,0.15s 淡入,2s 后淡出。bar 显隐在本层持有(ref + Transition 在子件),
// toast 经 emit 回父(父持有 toast reactive 容器)。
const providerBarVisible = ref(false)
let _providerBarTimer: ReturnType<typeof setTimeout> | null = null
function showProviderSwitched(modelName: string) {
// toast:经 emit 回父(单一 toast 源)
emit('provider-switched', modelName)
// bar:ref 控制显隐,Transition 驱动 0.15s 淡入/淡出
providerBarVisible.value = true
if (_providerBarTimer) clearTimeout(_providerBarTimer)
_providerBarTimer = setTimeout(() => { providerBarVisible.value = false }, 2000)
}
// 释放 provider 切换 bar 计时器
onBeforeUnmount(() => {
if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null }
})
</script>
<!-- ═══ TopBar 编排层 scoped 样式(c1c 拆分:仅留本层模板直含的容器规则,
区块样式随各子件迁移(共享工具类 .ai-tool-badge/.ai-btn-icon/.ai-pop 过渡
由消费子件各带 scoped 副本,全局 .ai-btn-icon 为抽取时旧样式不可依赖)。
CSS 变量(--df-*)由根级提供,共享同一份。 ═══ -->
<style scoped>
/* ═══ Header ═══ */
.ai-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 0.5px solid var(--df-border);
}
.ai-header-actions {
display: flex;
gap: 4px;
}
/* 底部工具区(标题左 + 工具右) */
.ai-bottom-tools {
display: flex;
align-items: center;
padding: 0 14px 4px;
}
.ai-bottom-title {
font-size: 11px;
color: var(--df-text-secondary);
min-width: 0;
margin-right: auto;
/* 长标题单行截断(防换行撑高工具行;右侧徽章族 flex-shrink:0,分离窄窗口更挤) */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-bottom-tools-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
margin-left: auto;
}
</style>