优化: 前端走查 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:
+99
-45
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user