重构: TopBar 布局 — header 中间区 + 底部工具区(标题/目标/历史/摘要)

This commit is contained in:
2026-06-28 03:30:31 +08:00
parent 53e1c1da77
commit 8723075360

View File

@@ -20,19 +20,34 @@
{{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
</div>
</div>
<!-- 模型选择器(对话透明化 L1:折叠为紧凑下拉) -->
<div class="ai-model-picker" :title="$t('aiChat.specifyModeHint') || ''">
<select
v-if="enabledModels.length"
class="ai-model-select ai-model-select--compact"
:value="selectedModelId"
@change="onModelSelect($event)"
<!-- 提供商状态 + 模型选择器 -->
<div class="ai-header-center">
<div
class="ai-provider-bar"
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
v-if="store.state.providers.length > 0"
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
@click="cycleProvider"
>
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
{{ modelOptionLabel(m) }}
</option>
</select>
<span v-else class="ai-model-empty">{{ $t('aiChat.noModels') }}</span>
<span class="provider-dot"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</div>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
</div>
<div class="ai-model-picker" :title="$t('aiChat.specifyModeHint') || ''">
<select
v-if="enabledModels.length"
class="ai-model-select ai-model-select--compact"
:value="selectedModelId"
@change="onModelSelect($event)"
>
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
{{ modelOptionLabel(m) }}
</option>
</select>
<span v-else class="ai-model-empty">{{ $t('aiChat.noModels') }}</span>
</div>
</div>
<div class="ai-header-actions">
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
@@ -92,36 +107,51 @@
</div>
</div>
<!-- 下半栏:provider + goals 并排 -->
<div class="ai-bottom-bar">
<!-- 提供商状态 -->
<div
class="ai-provider-bar"
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
v-if="store.state.providers.length > 0"
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
@click="cycleProvider"
>
<span class="provider-dot"></span>
<span class="provider-name">{{ activeProviderName }}</span>
</div>
<div class="ai-provider-bar ai-provider-empty" v-else>
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
</div>
<!-- 目标入口(扁平化,不抢眼) -->
<!-- 底部工具区:标题 + 目标 + 历史消息 + 上下文摘要 -->
<div class="ai-bottom-tools" ref="goalsRef">
<span v-if="currentConvTitle" class="ai-bottom-title">{{ currentConvTitle }}</span>
<div class="ai-bottom-tools-right">
<!-- 目标计数 -->
<div v-if="goals.length" class="ai-goals-inline">
<span class="ai-goals-inline-badge" @click="goalsExpanded = !goalsExpanded">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
<span class="ai-goals-inline-badge ai-tool-badge" @click="goalsExpanded = !goalsExpanded" title="查看目标">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
{{ goals.length }}
</span>
<div v-if="goalsExpanded" class="ai-goals-inline-list">
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item">
<span>{{ g }}</span>
<button class="ai-goal-remove" @click="removeGoal(i)">×</button>
<span class="ai-goal-text">{{ g }}</span>
<button class="ai-goal-remove" @click.stop="removeGoal(i)">×</button>
</div>
</div>
</div>
<!-- 历史消息 -->
<div v-if="historyMsgs.length" class="ai-history-inline">
<span class="ai-history-inline-badge ai-tool-badge" @click="historyExpanded = !historyExpanded" title="查看历史消息">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{{ historyMsgs.length }}
</span>
<div v-if="historyExpanded" class="ai-history-inline-list">
<div v-for="h in historyMsgs" :key="h.id" class="ai-history-inline-item" @click="scrollToMessage(h.id)">
<span class="ai-history-index">{{ h.num }}</span>
<span class="ai-history-text">{{ h.text }}</span>
<span class="ai-history-time">{{ h.time }}</span>
</div>
</div>
</div>
<!-- 上下文摘要 -->
<div v-if="summaryMsgs.length" class="ai-summary-inline">
<span class="ai-summary-inline-badge ai-tool-badge" @click="summaryExpanded = !summaryExpanded" title="查看上下文摘要">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h12"/></svg>
{{ summaryMsgs.length }}
</span>
<div v-if="summaryExpanded" class="ai-summary-inline-list">
<div v-for="(s, i) in summaryMsgs" :key="i" class="ai-summary-inline-item" @click="scrollToMessage(s.id)">
<span class="ai-summary-label">{{ $t('aiChat.compressedSummaryLabel') || '上下文摘要' }} #{{ summaryMsgs.length - i }}</span>
<p class="ai-summary-preview">{{ s.text }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model ,2s 后淡出) -->
@@ -137,11 +167,11 @@
// 第三批抽离: 顶部工具栏 TopBar(从 AiChat.vue 行 12-145 template + 行 741-902 script 迁移)
// 零行为变更。provider/model 选择逻辑直接读 store(单例共享),动作按钮 emit 回父,
// 父持有 clear/compress/confirm 逻辑 + 单一 toast 源(provider-switched 回父弹 toast)。
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api/ai'
import type { ModelConfig } from '../../api/types'
import type { AiMessage, ModelConfig } from '../../api/types'
defineProps<{
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
@@ -222,21 +252,114 @@ watch(() => store.state.activeConversationId, () => {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
})
// ── 当前对话标题 ──
const currentConvTitle = computed(() => {
const id = store.state.activeConversationId
if (!id) return ''
const conv = store.state.conversations.find(c => c.id === id)
return conv?.title || ''
})
// ── 🎯 对话目标面板(对话透明化 L1:Goal visibility) ──
// 从最新 switch 返回的 conversation summary 中取 pinned_goals,
// 增删操作经后端 IPC 持久化到 DB 并同步 per_conv 内存。
const goals = ref<string[]>([])
/** 目标列表展开/折叠 */
const goalsExpanded = ref(false)
/** 监听 activeConversationId 变化,加载目标的精简实现:从 store.conversations 查找当前会话的目标。 */
// ── 📋 历史消息(当前会话 user 消息列表) ──
interface HistoryItem { id: string; text: string; time: string; num: number }
/** 时间显示:今日 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 `昨天 ${hm}`
}
// 更早
return `${pad(d.getMonth()+1)}-${pad(d.getDate())} ${hm}`
}
const MAX_HISTORY = 20
const MAX_CHARS = 80
const historyMsgs = computed<HistoryItem[]>(() => {
const allUserMsgs = store.state.messages
.filter((m): m is AiMessage & { timestamp: number } => m.role === 'user' && !!m.content && !!m.timestamp)
.map((m, idx) => ({
id: m.id,
text: m.content.length > MAX_CHARS ? m.content.slice(0, MAX_CHARS) + '…' : m.content,
time: fmtTime(m.timestamp),
num: idx + 1,
}))
return allUserMsgs.slice(-MAX_HISTORY).reverse()
})
const historyExpanded = ref(false)
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
function scrollToMessage(msgId: string) {
historyExpanded.value = false
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位
const el = document.querySelector(`[data-msg-id="${msgId}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
/** 目标/历史下拉容器 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
}
}
// ── 📄 上下文摘要(压缩/归档的系统消息) ──
interface SummaryItem { id: string; text: string }
const summaryMsgs = computed<SummaryItem[]>(() => {
return store.state.messages
.filter(m => m.role === 'system' && !!m.content)
.map(m => ({
id: m.id,
text: m.content.length > 120 ? m.content.slice(0, 120) + '…' : m.content,
}))
})
const summaryExpanded = ref(false)
/** 按 Escape 关闭所有下拉 */
function onBottomToolsKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
goalsExpanded.value = false
historyExpanded.value = false
summaryExpanded.value = false
}
}
onMounted(() => {
document.addEventListener('click', onBottomToolsClickOutside)
document.addEventListener('keydown', onBottomToolsKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onBottomToolsClickOutside)
document.removeEventListener('keydown', onBottomToolsKeydown)
})
/** 监听 activeConversationId 变化,加载目标 */
watch(() => store.state.activeConversationId, (convId) => {
loadGoals(convId)
}, { immediate: true })
/** 消息变化后也刷新目标(发消息→save_conversation→DB→store 需重读) */
/** 消息变化后也刷新目标 */
watch(() => store.state.messages.length, () => {
// loadConversations 异步,等 store 刷新后重读
setTimeout(() => loadGoals(store.state.activeConversationId), 100)
})
@@ -246,13 +369,14 @@ function loadGoals(convId: string | null) {
goals.value = conv?.pinned_goals ?? []
}
/** 移除第 i 个目标:删除后持久化全量列表到后端。 */
/** 移除第 i 个目标(移除最后一个自动收起) */
async function removeGoal(i: number) {
const convId = store.state.activeConversationId
if (!convId) return
const next = [...goals.value]
next.splice(i, 1)
goals.value = next
if (next.length === 0) goalsExpanded.value = false
await aiApi.updateConversationGoals(convId, next)
}
@@ -317,6 +441,10 @@ onBeforeUnmount(() => {
font-size: 13px;
font-weight: 500;
color: var(--df-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
/* AE-2025-02:审批徽标(待审批>0 时显于头部,点击跳首个审批卡) */
.ai-approval-badge {
@@ -340,9 +468,6 @@ onBeforeUnmount(() => {
.ai-model-picker {
display: flex;
align-items: center;
margin-left: auto;
margin-right: 4px;
padding-left: 8px;
min-width: 0;
}
.ai-model-select {
@@ -360,8 +485,6 @@ onBeforeUnmount(() => {
}
.ai-model-select--compact {
max-width: 130px;
margin-left: auto;
margin-right: 4px;
}
.ai-model-select:disabled {
opacity: 0.6;
@@ -411,76 +534,27 @@ onBeforeUnmount(() => {
.ai-btn-spinner {
animation: ai-btn-spin 0.9s linear infinite;
}
/* ═══ 🎯 对话目标面板(对话透明化 L1) ═══ */
.ai-goals-panel {
padding: 6px 14px;
border-bottom: 0.5px solid var(--df-border);
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
}
.ai-goals-header {
/* 底部工具区(标题左 + 工具右) */
.ai-bottom-tools {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
justify-content: space-between;
padding: 0 14px 4px;
}
.ai-goals-title {
.ai-bottom-title {
font-size: 11px;
font-weight: 500;
color: var(--df-accent);
color: var(--df-text-secondary);
min-width: 0;
flex: 1;
margin-right: 8px;
}
.ai-goals-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.ai-goal-item {
.ai-bottom-tools-right {
display: flex;
align-items: center;
gap: 4px;
padding: 1px 0;
}
.ai-goal-icon {
font-size: 10px;
flex-shrink: 0;
}
.ai-goal-text {
font-size: 11px;
color: var(--df-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.ai-goal-remove {
border: none;
background: transparent;
color: var(--df-text-dim);
cursor: pointer;
font-size: 13px;
padding: 0 2px;
line-height: 1;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.ai-goal-item:hover .ai-goal-remove {
opacity: 1;
}
.ai-goal-remove:hover {
color: var(--df-danger);
}
/* ═══ Provider Bar + Goals(并排) ═══ */
.ai-bottom-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 14px;
border-bottom: 0.5px solid var(--df-border);
min-height: 26px;
}
/* ═══ Provider Bar (header 中间区) ═══ */
.ai-provider-bar {
display: inline-flex;
align-items: center;
@@ -510,33 +584,152 @@ onBeforeUnmount(() => {
background: var(--df-danger-bg);
}
/* 目标入口(扁平低调) */
/* 历史消息(底部) */
.ai-history-inline {
position: relative;
display: inline-flex;
align-items: center;
}
/* 底部工具通用徽标 */
.ai-tool-badge {
cursor: pointer;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--df-text);
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
transition: color 0.15s, background 0.15s;
white-space: nowrap;
}
.ai-tool-badge:hover {
background: var(--df-bg-card-hover);
}
/* 目标计数(底部) */
.ai-goals-inline {
position: relative;
display: inline-flex;
align-items: center;
}
.ai-goals-inline-badge {
.ai-history-inline-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 240px;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 4px 0;
margin-top: 2px;
}
.ai-history-inline-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
color: var(--df-text-secondary);
transition: background 0.1s;
}
.ai-history-inline-item:hover {
background: var(--df-bg-card);
color: var(--df-text);
}
.ai-history-index {
flex-shrink: 0;
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
color: var(--df-text-dim);
min-width: 24px;
text-align: right;
font-family: var(--df-font-mono);
}
.ai-history-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.ai-history-time {
flex-shrink: 0;
font-size: 10px;
color: var(--df-text-dim);
font-family: var(--df-font-mono);
}
/* ═══ 上下文摘要(底部) ═══ */
.ai-summary-inline {
position: relative;
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--df-text-dim);
background: transparent;
transition: color 0.15s, background 0.15s;
opacity: 0.6;
}
.ai-goals-inline-badge:hover {
opacity: 1;
.ai-summary-inline-list {
position: absolute;
left: auto; right: 0;
top: 100%;
z-index: 100;
min-width: 280px;
max-width: 420px;
max-height: 360px;
overflow-y: auto;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 6px 0;
margin-top: 2px;
}
.ai-summary-inline-item {
padding: 6px 10px;
cursor: pointer;
border-bottom: 0.5px solid var(--df-border);
transition: background 0.1s;
}
.ai-summary-inline-item:last-child { border-bottom: none; }
.ai-summary-inline-item:hover {
background: var(--df-bg-card);
}
.ai-summary-label {
display: block;
font-size: 10px;
font-weight: 600;
color: var(--df-accent);
margin-bottom: 2px;
}
.ai-summary-preview {
margin: 0;
font-size: 11px;
color: var(--df-text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* header 中间区:provider + 模型选择器并排 */
.ai-header-center {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
justify-content: center;
min-width: 0;
}
.ai-goals-inline-list {
position: absolute;
top: 100%; left: 0;
left: auto; right: 0;
z-index: 100;
min-width: 200px;
max-width: 360px;
@@ -550,13 +743,21 @@ onBeforeUnmount(() => {
.ai-goal-inline-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 3px 8px;
gap: 4px;
padding: 3px 6px;
font-size: 11px;
cursor: pointer;
border-radius: 2px;
}
.ai-goal-inline-item:hover {
background: var(--df-bg-card, #f5f5f5);
background: var(--df-bg-card);
}
.ai-goal-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-goal-remove:hover {
color: var(--df-danger, #e5484d);