Files
DevFlow/src/components/ai/top-bar/MoreMenu.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

166 lines
7.8 KiB
Vue

<template>
<!-- ══ 更多菜单(...):收纳低频的上下文管理操作(清空上下文/压缩上下文)══ -->
<!-- 使用频率分级:新建/清空对话/窗口控制是高频常用,上下文管理是低频项;
收进 popout 降低顶部视觉密度,需时从 ... 入口展开。点击外部收起。 -->
<div class="ai-more-menu" ref="moreMenuRef">
<button
class="ai-btn-icon"
:class="{ 'ai-btn-icon--active': moreMenuOpen }"
:title="$t('aiChat.moreActions')"
:aria-label="$t('aiChat.moreActions')"
@click="moreMenuOpen = !moreMenuOpen"
>
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>
</button>
<Transition name="ai-more-popout">
<div v-if="moreMenuOpen" class="ai-more-popout" role="menu">
<!-- F-15 阶段2: 清空上下文(历史消息归档保留,不删 DB;新对话不受影响) -->
<button
class="ai-more-item"
:disabled="!hasActiveMessages || store.state.streaming"
@click="emit('clear-context'); moreMenuOpen = false"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>
<span>{{ $t('aiChat.clearContext') }}</span>
</button>
<!-- F-15 阶段2: 压缩上下文(LLM 摘要落 system + 历史消息标 compressed 归档) -->
<button
class="ai-more-item"
:disabled="!hasActiveMessages || store.state.streaming || store.isCompressing.value"
@click="emit('compress-context'); moreMenuOpen = false"
>
<svg aria-hidden="true" v-if="!store.isCompressing.value" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
<svg aria-hidden="true" v-else class="ai-btn-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
<span>{{ store.isCompressing.value ? $t('aiChat.compressing') : $t('aiChat.compressContext') }}</span>
</button>
<!-- I12(2026-10-05):快捷键速查入口(零快捷键提示补课;面板体在 AiChat Teleport 渲染) -->
<button
class="ai-more-item"
@click="emit('show-shortcuts'); moreMenuOpen = false"
>
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6.01" y2="10"/><line x1="10" y1="10" x2="10.01" y2="10"/><line x1="14" y1="10" x2="14.01" y2="10"/><line x1="18" y1="10" x2="18.01" y2="10"/><line x1="7" y1="14" x2="7.01" y2="14"/><line x1="11" y1="14" x2="11.01" y2="14"/><line x1="15" y1="14" x2="15.01" y2="14"/></svg>
<span>{{ $t('aiChat.shortcutsTitle') }}</span>
</button>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
// TopBar 更多菜单 popout(c1c 拆分自 TopBar.vue,零行为变更):
// 低频的清空/压缩上下文收入 ... popout;点击按钮切换,点击外部收起,ESC 收起(键盘友好)。
// 动作经 emit 回父(父持有 clear/compress/confirm 逻辑 + 单一 toast 源)。
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useAiStore } from '../../../stores/ai'
defineProps<{
/** 有活跃消息(清空/压缩按钮启用条件;父 computed) */
hasActiveMessages: boolean
}>()
const emit = defineEmits<{
/** 清空上下文(归档保留;父 handleClearContext) */
(e: 'clear-context'): void
/** 压缩上下文(LLM 摘要;父 handleCompressContext) */
(e: 'compress-context'): void
/** I12 展示快捷键速查面板(父 shortcutsVisible=true,面板体在父 Teleport 到 body) */
(e: 'show-shortcuts'): void
}>()
const store = useAiStore()
// ══ 更多菜单 popout 状态(上下文管理操作收纳)══
// 顶部按钮过多时,低频的清空/压缩上下文收入 ... popout;点击按钮切换,点击外部收起。
const moreMenuOpen = ref(false)
const moreMenuRef = ref<HTMLElement | null>(null)
function onDocClickCloseMoreMenu(e: MouseEvent) {
if (!moreMenuOpen.value) return
const root = moreMenuRef.value
if (root && !root.contains(e.target as Node)) {
moreMenuOpen.value = false
}
}
onMounted(() => document.addEventListener('click', onDocClickCloseMoreMenu))
onBeforeUnmount(() => document.removeEventListener('click', onDocClickCloseMoreMenu))
// ESC 键收起更多菜单(键盘友好)
function onEscCloseMoreMenu(e: KeyboardEvent) {
if (e.key === 'Escape' && moreMenuOpen.value) moreMenuOpen.value = false
}
onMounted(() => document.addEventListener('keydown', onEscCloseMoreMenu))
onBeforeUnmount(() => document.removeEventListener('keydown', onEscCloseMoreMenu))
</script>
<style scoped>
/* ── 图标按钮(共享工具类):wave2 样式仅存在于原 TopBar scoped(全局 components.css
副本为抽取时旧样式,逐字不一致),拆分子件各自带本副本防视觉回归 ── */
.ai-btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-md);
background: var(--df-bg-card);
color: var(--df-text-dim);
cursor: pointer;
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
}
.ai-btn-icon:hover {
border-color: var(--df-border-strong);
background: var(--df-bg-card-hover);
color: var(--df-text-secondary);
}
.ai-btn-icon--active {
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
background: var(--df-accent-bg);
color: var(--df-accent);
}
.ai-btn-icon:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ai-btn-icon:disabled:hover {
border-color: var(--df-border);
background: var(--df-bg-card);
color: var(--df-text-dim);
}
/* ── 更多菜单 popout(清空/压缩上下文等低频操作收纳;CSS 原缺失致打开样式错乱)── */
.ai-more-menu { position: relative; } /* popout absolute 定位基准 */
.ai-more-popout {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 168px;
padding: 4px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
box-shadow: var(--df-shadow-sm);
z-index: 100;
display: flex;
flex-direction: column;
gap: 2px;
}
.ai-more-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: none;
border-radius: var(--df-radius-sm);
background: transparent;
color: var(--df-text-secondary);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.ai-more-item:hover:not(:disabled) { background: var(--df-bg-card-hover); color: var(--df-text); }
.ai-more-item:disabled { opacity: 0.35; cursor: not-allowed; }
/* Transition:淡入 + 下滑 */
.ai-more-popout-enter-active, .ai-more-popout-leave-active { transition: opacity .15s, transform .15s; }
.ai-more-popout-enter-from, .ai-more-popout-leave-to { opacity: 0; transform: translateY(-4px); }
/* 压缩中 spinner:.ai-btn-spinner(含 ai-btn-spin 旋转动画)由 components.css 全局定义,
原本地重复 animation 声明删除(2026-10-18 整洁波,逐字重复零视觉变更) */
</style>