优化: 前端u-agent令牌对齐与组件拆分续批
- 全局样式类(df-sec-h/df-toast族)替换散装实现,主题token去硬编码兜底 - 拆分子组件:ai-chat/queue-panel(队列时钟与编辑随迁)、 conversation-sidebar/ConversationList、settings/provider-panel - 父组件死副本清理(队列编辑/时钟/键位表,TS6133清零)并补 fill-draft父侧转发(重构半成品断链修复) - 验收:vue-tsc EXIT 0
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
|
||||
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
|
||||
单轮问答 agentRound 恒 0,不显示(避免误显)。
|
||||
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
|
||||
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
|
||||
完成聚合语义由 MessageList 的执行轨迹卡承接。
|
||||
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。
|
||||
C4b 拆分自 AiChat.vue(纯展示,store 单例直读,零行为变更)。 -->
|
||||
<div v-if="showAgenticProgress" class="ai-agentic-progress">
|
||||
<span class="ai-agentic-progress-dot"></span>
|
||||
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { collectTurnStats, turnMessages } from '../../composables/ai/useTurnTrace'
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。
|
||||
const showAgenticProgress = computed(() =>
|
||||
store.state.streaming && store.state.agentRound > 0,
|
||||
)
|
||||
// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。
|
||||
// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议);
|
||||
// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。
|
||||
// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。
|
||||
const agenticProgressText = computed(() => {
|
||||
const pending = store.state.pendingApprovals.length
|
||||
const total = collectTurnStats(turnMessages(store.state.messages)).total
|
||||
return pending > 0
|
||||
? t('ai.agenticProgress', { round: store.state.agentRound, total, pending })
|
||||
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, total })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ═══ Agentic 循环进度条(样式随区块自 AiChat.vue 迁入,零视觉变更) ═══ */
|
||||
.ai-agentic-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
padding: 3px 8px;
|
||||
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.ai-agentic-progress-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
animation: ai-agentic-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.ai-agentic-progress-text {
|
||||
font-family: var(--df-font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@keyframes ai-agentic-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<!-- ═══ 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目)
|
||||
C4b 拆分自 AiChat.vue 队列区(模板 + L2 发送韧性 + V7 排队时钟 + 队列编辑/回填,零行为变更;
|
||||
store 单例共享,confirm 自持一份 useConfirm + ConfirmDialog——同 ChatInput/ConversationSidebar
|
||||
模式:useConfirm 非单例,子组件发起的 confirm 须本组件渲染) ═══ -->
|
||||
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
|
||||
<div class="ai-queue-head">
|
||||
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
|
||||
<div class="ai-queue-actions">
|
||||
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
|
||||
<button class="ai-queue-clear" @click="handleClearQueue">{{ $t('aiChat.clearQueue') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-queue-list">
|
||||
<div v-for="(item, idx) in activeQueue" :key="idx" class="ai-queue-item">
|
||||
<!-- 编辑态只编 text:inline input,回车/失焦写回,ESC 取消 -->
|
||||
<template v-if="editingQueueIdx === idx">
|
||||
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
||||
<input
|
||||
v-model="editingQueueText"
|
||||
class="ai-queue-edit-input"
|
||||
type="text"
|
||||
@keydown="onQueueEditKeydown"
|
||||
@blur="saveEditQueued"
|
||||
/>
|
||||
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')" :aria-label="$t('aiChat.queueSave')">✓</button>
|
||||
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')" :aria-label="$t('aiChat.queueCancel')">✕</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
||||
<span class="ai-queue-text" :title="item.text">{{ item.text }}</span>
|
||||
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
|
||||
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
|
||||
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
|
||||
<button class="ai-queue-btn" @click="backfillQueued(idx)" :title="$t('aiChat.queueBackfill')">{{ $t('aiChat.queueBackfill') }}</button>
|
||||
<!-- 编辑按钮 -->
|
||||
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
|
||||
<!-- 立即发送(打断当前+发本条) -->
|
||||
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
|
||||
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')" :aria-label="$t('common.cancel')">×</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 确认弹层(强制发送/清空/回填覆盖二次确认;同 ChatInput 自持模式) -->
|
||||
<ConfirmDialog
|
||||
:visible="confirmState.visible"
|
||||
:msg="confirmState.msg"
|
||||
:danger-label="confirmState.dangerLabel"
|
||||
@result="answerConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import { useConfirm } from '../../composables/useConfirm'
|
||||
import type { SkillInfo } from '../../api/types'
|
||||
|
||||
// 回填链路跨组件:输入框 ref(chatInputRef)在父,本组件经 props 读草稿态(hasUnsentInput)、
|
||||
// 经 emit 写草稿(fill-draft),由父转发 ChatInput——共享函数留主文件 props/emits 传(拆分纪律)。
|
||||
const props = defineProps<{
|
||||
/** 父侧 ChatInput.hasUnsentInput 转发(回填覆盖未发送草稿前的 confirm 守卫) */
|
||||
hasUnsentInput: () => boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 队列项回填:父转发 ChatInput.fillDraft(text, skill) 写入输入框草稿 */
|
||||
(e: 'fill-draft', text: string, skill: SkillInfo | null): void
|
||||
}>()
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// ── 确认弹层(useConfirm 非单例,子组件自持一份 + 自渲染 ConfirmDialog,同 ChatInput 先例) ──
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。
|
||||
* 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示
|
||||
* 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */
|
||||
const activeQueue = computed(() =>
|
||||
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
|
||||
)
|
||||
|
||||
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
|
||||
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
|
||||
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
|
||||
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
|
||||
const nowTick = ref(Date.now())
|
||||
let queueTickTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。
|
||||
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed
|
||||
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick
|
||||
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
|
||||
const queueTimedOut = computed(() => {
|
||||
void nowTick.value
|
||||
return store.isQueueTimedOut(store.state.activeConversationId)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => activeQueue.value.length > 0,
|
||||
(waiting) => {
|
||||
if (waiting && queueTickTimer === null) {
|
||||
nowTick.value = Date.now()
|
||||
queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000)
|
||||
} else if (!waiting && queueTickTimer !== null) {
|
||||
clearInterval(queueTickTimer)
|
||||
queueTickTimer = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */
|
||||
function formatWaitTime(totalSeconds: number): string {
|
||||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */
|
||||
function queueItemWait(item: { enqueuedAt: number }): string {
|
||||
return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000)))
|
||||
}
|
||||
|
||||
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
|
||||
async function handleForceSend() {
|
||||
await store.tryForceSend(confirmDialog, store.state.activeConversationId)
|
||||
}
|
||||
|
||||
/** UI-4(看板 943)清空队列:二次确认后全清当前活跃会话排队消息。
|
||||
* 一键误触会静默丢掉全部排队消息且不可恢复,复用 ConfirmDialog 先例
|
||||
* (同 tryForceSend 的 confirmFn 注入形态),不引 window.confirm(仓风格)。 */
|
||||
async function handleClearQueue() {
|
||||
const convId = store.state.activeConversationId
|
||||
if (!convId) return
|
||||
const confirmed = await confirmDialog(t('aiChat.clearQueueConfirm'))
|
||||
if (confirmed) store.clearQueue(convId)
|
||||
}
|
||||
|
||||
// ── 队列消息编辑(只编 text)+ 立即发送(插队)──
|
||||
|
||||
/** 当前正在编辑的队列项 index;null=非编辑态 */
|
||||
const editingQueueIdx = ref<number | null>(null)
|
||||
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
|
||||
const editingQueueText = ref('')
|
||||
|
||||
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue
|
||||
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
|
||||
// 新会话同下标队列项上,切会话即重置(原随父 watch activeConversationId,拆分后本组件自管)。
|
||||
watch(() => store.state.activeConversationId, () => {
|
||||
if (editingQueueIdx.value !== null) cancelEditQueued()
|
||||
})
|
||||
|
||||
/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */
|
||||
function startEditQueued(idx: number) {
|
||||
const item = activeQueue.value[idx]
|
||||
if (!item) return
|
||||
editingQueueIdx.value = idx
|
||||
editingQueueText.value = item.text
|
||||
}
|
||||
|
||||
/** 取消编辑:还原非编辑态,不写回 */
|
||||
function cancelEditQueued() {
|
||||
editingQueueIdx.value = null
|
||||
editingQueueText.value = ''
|
||||
}
|
||||
|
||||
/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
|
||||
* 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫,
|
||||
* 收敛到本函数统一判。 */
|
||||
function onQueueEditKeydown(e: KeyboardEvent) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
saveEditQueued()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditQueued()
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
|
||||
function saveEditQueued() {
|
||||
if (editingQueueIdx.value === null) return
|
||||
store.editQueued(editingQueueIdx.value, editingQueueText.value)
|
||||
editingQueueIdx.value = null
|
||||
editingQueueText.value = ''
|
||||
}
|
||||
|
||||
/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */
|
||||
async function handleSendQueuedNow(idx: number) {
|
||||
await store.sendQueuedNow(idx)
|
||||
}
|
||||
|
||||
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
|
||||
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
|
||||
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
|
||||
* 与队列原地编辑只保 text 同一取舍。 */
|
||||
async function backfillQueued(idx: number) {
|
||||
const item = activeQueue.value[idx]
|
||||
if (!item) return
|
||||
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
|
||||
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
|
||||
if (props.hasUnsentInput()) {
|
||||
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return
|
||||
}
|
||||
const skill = item.skill
|
||||
? store.state.skills.find(s => s.name === item.skill) ?? null
|
||||
: null
|
||||
store.cancelQueued(idx)
|
||||
emit('fill-draft', item.text, skill)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
|
||||
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ═══ 待发送队列(样式随区块自 AiChat.vue 迁入,零视觉变更) ═══ */
|
||||
.ai-queue {
|
||||
margin-bottom: 6px;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-queue--timeout {
|
||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
}
|
||||
.ai-queue-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ai-queue-title { font-size: 11px; color: var(--df-text-dim); font-weight: 600; }
|
||||
.ai-queue--timeout .ai-queue-title { color: var(--df-warning); }
|
||||
.ai-queue-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.ai-queue-force {
|
||||
background: var(--df-warning); border: none; color: #000; cursor: pointer;
|
||||
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: calc(var(--df-radius) - 2px);
|
||||
}
|
||||
.ai-queue-force:hover { filter: brightness(1.1); }
|
||||
.ai-queue-clear { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 11px; padding: 0; }
|
||||
.ai-queue-clear:hover { color: var(--df-text); }
|
||||
.ai-queue-list { display: flex; flex-direction: column; gap: 3px; }
|
||||
.ai-queue-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
background: var(--df-bg-card);
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-queue-skill { color: var(--df-accent); font-weight: 600; flex-shrink: 0; }
|
||||
/* V7 排队时钟:每项已等待时长 */
|
||||
.ai-queue-wait { color: var(--df-text-dim); font-size: 10px; flex-shrink: 0; opacity: 0.7; }
|
||||
.ai-queue-text {
|
||||
color: var(--df-text);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-queue-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; }
|
||||
.ai-queue-x:hover { color: var(--df-danger); }
|
||||
/* 队列项编辑/立即发送按钮 + 编辑态 input */
|
||||
.ai-queue-btn {
|
||||
background: none; border: none; color: var(--df-text-dim); cursor: pointer;
|
||||
font-size: 11px; line-height: 1; padding: 0 4px; flex-shrink: 0;
|
||||
}
|
||||
.ai-queue-btn:hover { color: var(--df-accent); }
|
||||
.ai-queue-edit-input {
|
||||
flex: 1; min-width: 0; font-size: 12px; padding: 1px 4px;
|
||||
background: var(--df-bg-card); color: var(--df-text);
|
||||
border: 0.5px solid var(--df-accent);
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
outline: none;
|
||||
}
|
||||
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||
.ai-queue-edit-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
|
||||
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts)。
|
||||
C4b 拆分自 AiChat.vue(visible 状态留父——onGlobalKeydown Esc 仲裁需读写,开/关经 props/emits) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="ai-shortcuts-mask" @click.self="emit('close')">
|
||||
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
|
||||
<div class="ai-shortcuts-head">
|
||||
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
|
||||
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
<ul class="ai-shortcuts-list">
|
||||
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
|
||||
<span class="ai-shortcuts-key">{{ s.key }}</span>
|
||||
<span class="ai-shortcuts-act">{{ s.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
/** 面板显隐(状态留父:TopBar emit show-shortcuts 置 true,onGlobalKeydown Esc 分支仲裁) */
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 键位表 shortcuts 与父 AiChat onGlobalKeydown 同族——键区调整时两边同步,防速查与真实键位漂移。
|
||||
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略)。
|
||||
const shortcuts = computed(() => [
|
||||
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
|
||||
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
|
||||
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
|
||||
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
|
||||
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
|
||||
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
|
||||
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
|
||||
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关;样式随区块自 AiChat.vue 迁入) ═══ */
|
||||
.ai-shortcuts-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ai-shortcuts-panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg, 8px);
|
||||
box-shadow: var(--df-shadow-sm);
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 48px);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.ai-shortcuts-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ai-shortcuts-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-shortcuts-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--df-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-card); }
|
||||
.ai-shortcuts-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-shortcuts-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ai-shortcuts-key {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-shortcuts-act { color: var(--df-text-secondary); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user