优化: 前端走查 P2+UX(status union + ApprovalPopup hack消除/拖动JS API/Esc)

- status union: AiMessage 加 status 字面量联合,删 AiChat/MessageList AiMessageWithStatus 重复+cast
- ApprovalPopup: 删 _placeholder hack(toolDisplayName 提纯函数) + dispatchApprovalIPC 共享分派消除三套重复 + always 按钮语义对齐 + closeWithFallback 兜底 + 拖动 JS API startDragging(避子元素覆盖) + Esc 快捷键
This commit is contained in:
lxy
2026-08-02 10:43:30 +08:00
parent ef77120912
commit f1773dde4f
7 changed files with 234 additions and 136 deletions
+99 -45
View File
@@ -4,7 +4,7 @@
- 无边框(decorations:false) + alwaysOnTop + skipTaskbar → 系统级可见
- 紫色主题自定义样式(非 OS notification 默认)
- 哑渲染器:监听主窗口推送的 approval-popup-update 事件,不重复监听 AI 事件
- 操作按钮直调 aiApi.approve/authorizeDir(主窗口 listener 收后端 emit → 推新快照)
- 操作按钮走 dispatchApprovalIPC(按 tc.kind 分派 aiApi.approve/authorizeDir;主窗口 listener 收后端 emit → 推新快照)
- 点击「打开审批面板」emit approval-popup-activate → 主窗口聚焦 + togglePanel
- 拖动条:.popup-drag(region CSS,WebviewWindow dataDrag 守卫)实现窗口拖动
- 自动定位屏幕右上角(同 ApprovalOverlay 视觉一致) -->
@@ -42,7 +42,7 @@
>
<!-- 工具名 + kind 徽章 -->
<div class="popup-item-header">
<span class="popup-item-name">{{ displayName(tc) }}</span>
<span class="popup-item-name">{{ toolDisplayName(tc) }}</span>
<span v-if="tc.kind === 'path'" class="popup-item-tag popup-item-tag--path">
{{ $t('aiChat.popupTagPath') }}
</span>
@@ -56,8 +56,36 @@
<div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir">
📁 {{ tc.dir }}
</div>
<!-- 操作按钮组 -->
<div class="popup-item-actions">
<!-- 操作按钮组:path 类显 once/always/deny 三选项(与 ToolCard 同款,补 always);
risk 类显 approve/reject 两选项。IPC 分派统一走 dispatchApprovalIPC。 -->
<div v-if="tc.kind === 'path'" class="popup-item-actions popup-item-actions--path">
<button
class="popup-btn popup-btn--approve"
:disabled="processingId === tc.id"
@click.stop="onApproveOnce(tc)"
>
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthOnce') }}
</button>
<button
class="popup-btn popup-btn--always"
:disabled="processingId === tc.id"
@click.stop="onApproveAlways(tc)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ $t('aiChat.dirAuthAlways') }}
</button>
<button
class="popup-btn popup-btn--reject"
:disabled="processingId === tc.id"
@click.stop="onReject(tc)"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{{ $t('aiChat.dirAuthDeny') }}
</button>
</div>
<div v-else class="popup-item-actions">
<button
class="popup-btn popup-btn--approve"
:disabled="processingId === tc.id"
@@ -65,7 +93,7 @@
>
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{{ tc.kind === 'path' ? $t('aiChat.dirAuthOnce') : $t('aiTool.approve') }}
{{ $t('aiTool.approve') }}
</button>
<button
class="popup-btn popup-btn--reject"
@@ -93,8 +121,8 @@
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { aiApi } from '@/api'
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
import type { AiToolCallInfo } from '@/api/types'
@@ -105,16 +133,6 @@ const processingId = ref<string>('')
/** 拖动态(样式反馈) */
const isDragging = ref(false)
// 工具显示名复用 ToolCard 同款逻辑(语义化回显:写入/读取/$ cmd/GET host 等)。
// useToolCardHeader 的 getTc 入参仅供 displayArgValue/argsEntries 使用,toolDisplayName
// 直接接受 tc 参数(getTc 不被 toolDisplayName 调用),故传一个无副作用占位 getter 即可。
// 占位 getter 返回一个空 name 的对象,确保 displayArgValue 等即便误调也不会抛错。
const _placeholder = { name: '' } as AiToolCallInfo
const { toolDisplayName } = useToolCardHeader(() => _placeholder)
function displayName(tc: AiToolCallInfo): string {
return toolDisplayName(tc)
}
let _unlistenUpdate: UnlistenFn | null = null
let _positioned = false
/** 关窗兜底:防止 close() promise 卡住或被拒导致「正在关闭…」永久停留。
@@ -123,6 +141,15 @@ let _positioned = false
let _closeFallbackTimer: ReturnType<typeof setTimeout> | null = null
const CLOSE_TIMEOUT_MS = 1500
/** Esc 键关浮窗(键盘可达性)。模块级命名函数,onBeforeUnmount 据此精确移除同一引用。
* 仅响应 Esc(KeyDown),其他键透传(不拦截 Tab/Enter 等原生 button 导航)。 */
function _onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
void onClose()
}
}
onMounted(async () => {
// 首屏定位到屏幕右上角(只定位一次,后续保留用户拖动后位置)
if (!_positioned) {
@@ -164,11 +191,17 @@ onMounted(async () => {
// 用 request-reply 太重,直接 emit 一个轻量事件让主窗口重推快照
await emit('approval-popup-ready', {})
} catch { /* ignore */ }
// Esc 快捷键关浮窗(键盘可达性:无需鼠标即可关)。Tab/Enter 原生 button 已支持,不另加。
// _onKeyDown 是模块级引用,onBeforeUnmount 据此 removeEventListener(保证移除的是同一引用)
window.addEventListener('keydown', _onKeyDown)
})
onBeforeUnmount(() => {
_unlistenUpdate?.()
_unlistenUpdate = null
// 清 Esc listener(防组件卸载后仍监听 window 致内存泄漏/重复触发)
window.removeEventListener('keydown', _onKeyDown)
if (_closeFallbackTimer) {
clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null
@@ -214,41 +247,37 @@ async function closeWithFallback(): Promise<void> {
}
}
/** 批准:risk 类 approve(true),path 类 authorizeDir('once') */
/** path 类:本次会话授权(decision='once') */
async function onApproveOnce(tc: AiToolCallInfo) {
await runApproval(tc, false, 'once')
}
/** path 类:写持久白名单(decision='always') */
async function onApproveAlways(tc: AiToolCallInfo) {
await runApproval(tc, false, 'always')
}
/** risk 类:批准(approved=true) */
async function onApprove(tc: AiToolCallInfo) {
if (processingId.value === tc.id) return
processingId.value = tc.id
try {
if (tc.kind === 'path') {
await aiApi.authorizeDir(tc.id, 'once')
} else {
await aiApi.approve(tc.id, true)
}
// 乐观移除(主窗口 listener 收后端 emit 后会推新快照覆盖,此乐观仅缩短视觉延迟)
approvals.value = approvals.value.filter(a => a.id !== tc.id)
// BUG2 修复:乐观移除后若列表空,立即关浮窗(避免空列表停留在 popup-empty 永久转圈态)
await maybeAutoClose()
} catch (e) {
console.error('[ApprovalPopup] 批准失败:', e)
} finally {
processingId.value = ''
}
await runApproval(tc, true)
}
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派) */
async function onReject(tc: AiToolCallInfo) {
await runApproval(tc, false, tc.kind === 'path' ? 'deny' : undefined)
}
/** 拒绝:risk 类 approve(false),path 类 authorizeDir('deny') */
async function onReject(tc: AiToolCallInfo) {
/** 审批执行统一壳:防抖(processingId)+ 调共享 IPC 分派 + 乐观移除 + 空列表自动关浮窗。
* IPC 分派规则(path→authorizeDir / risk→approve)抽离至 dispatchApprovalIPC,
* 与 useAiApproval.approveToolCall 同源,消除「同语义两套逻辑」漂移。 */
async function runApproval(tc: AiToolCallInfo, approved: boolean, decision?: 'once' | 'always' | 'deny') {
if (processingId.value === tc.id) return
processingId.value = tc.id
try {
if (tc.kind === 'path') {
await aiApi.authorizeDir(tc.id, 'deny')
} else {
await aiApi.approve(tc.id, false)
}
await dispatchApprovalIPC(tc, approved, decision)
// 乐观移除(主窗口 listener 收后端 emit 后会推新快照覆盖,此乐观仅缩短视觉延迟)
approvals.value = approvals.value.filter(a => a.id !== tc.id)
// BUG2 修复:乐观移除后若列表空,立即关浮窗(避免空列表停留 popup-empty 永久转圈态)
await maybeAutoClose()
} catch (e) {
console.error('[ApprovalPopup] 拒绝失败:', e)
console.error('[ApprovalPopup] 审批失败:', e)
} finally {
processingId.value = ''
}
@@ -294,8 +323,24 @@ async function onClose() {
}
}
// 拖动样式反馈(实际拖动由 data-tauri-drag-region + Tauri dataDrag 守卫处理)
function onDragStart() { isDragging.value = true }
// 拖动:JS API 主动驱动 + data-tauri-drag-region 双保险。
// 根因:Tauri 2.x data-tauri-drag-region 默认不冒泡到子元素(svg/span/title/close 按钮"吃掉"
// 鼠标事件),用户拖到子元素上拖不动(只有 header 空白缝隙可拖)。JS API startDragging() 绕过此限制——
// 主动 mousedown 调用,不依赖属性冒泡;data-tauri-drag-region 保留作 fallback(若 JS API 失败仍可拖)。
// button 区域不拖(closest('button') 跳过),保证审批按钮点击优先(mousedown 冒泡到 header 时不触发拖动)。
async function onDragStart(e: MouseEvent) {
// 仅左键触发拖动(右键/中键忽略,防误触);button 元素的 mousedown 不拖(让点击优先)
if (e.button !== 0) return
if ((e.target as HTMLElement)?.closest('button')) return
isDragging.value = true
try {
const win = getCurrentWebviewWindow()
await win.startDragging()
} catch (err) {
// JS API 失败时降级:data-tauri-drag-region 属性仍在 header 上,原生守卫接管
console.warn('[ApprovalPopup] startDragging() 失败,降级到 data-tauri-drag-region:', err)
}
}
function onDragEnd() { isDragging.value = false }
</script>
@@ -500,6 +545,15 @@ function onDragEnd() { isDragging.value = false }
.popup-btn--approve:hover:not(:disabled) {
background: var(--df-accent-hover, #6558d8);
}
.popup-btn--always {
background: var(--df-accent-bg);
color: var(--df-accent);
border: 0.5px solid var(--df-accent);
}
.popup-btn--always:hover:not(:disabled) {
background: var(--df-accent);
color: #fff;
}
.popup-btn--reject {
background: var(--df-sidebar-hover);
color: var(--df-text-dim);