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