844 lines
34 KiB
Vue
844 lines
34 KiB
Vue
<template>
|
|
<!-- ═══ 系统级审批提示浮窗(独立 WebviewWindow 内容)═══
|
|
设计:
|
|
- 无边框(decorations:false) + alwaysOnTop + skipTaskbar → 系统级可见
|
|
- 紫色主题自定义样式(非 OS notification 默认)
|
|
- 哑渲染器:监听主窗口推送的 approval-popup-update 事件,不重复监听 AI 事件
|
|
- 操作按钮走 dispatchApprovalIPC(按 tc.kind 分派 aiApi.approve/authorizeDir;主窗口 listener 收后端 emit → 推新快照)
|
|
- 点击「打开审批面板」emit approval-popup-activate → 主窗口聚焦 + togglePanel
|
|
- 拖动条:.popup-drag(region CSS,WebviewWindow dataDrag 守卫)实现窗口拖动
|
|
- 自动定位屏幕右上角(同 ApprovalOverlay 视觉一致) -->
|
|
<div class="popup-shell" :data-dragging="isDragging">
|
|
<!-- 顶部拖动条(双击不最大化,因 decorations:false) -->
|
|
<div class="popup-header" data-tauri-drag-region @mousedown="onDragStart" @mouseup="onDragEnd">
|
|
<div class="popup-header-icon">
|
|
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M9 11l3 3L22 4" />
|
|
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
|
</svg>
|
|
<span v-if="approvals.length > 1" class="popup-header-badge">{{ approvals.length }}</span>
|
|
</div>
|
|
<div class="popup-header-title">{{ $t('aiChat.popupTitle') }}</div>
|
|
<button class="popup-header-close" :title="$t('common.close')" @click.stop="onClose">
|
|
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 待审批列表 -->
|
|
<div class="popup-body">
|
|
<div v-if="approvals.length === 0" class="popup-empty">
|
|
<!-- BUG2 修复:空态不再永久转圈(原 popup-empty-spinner 致「处理完仍转圈」误判卡死)。
|
|
空态正常会被 maybeAutoClose 即时关窗;此 hint 仅作防御性兜底(close 失败时给可见反馈)。 -->
|
|
<span class="popup-empty-hint">{{ $t('aiChat.popupEmptyHint') }}</span>
|
|
</div>
|
|
<div
|
|
v-for="tc in approvals"
|
|
:key="tc.id"
|
|
class="popup-item"
|
|
:class="'popup-item--' + (tc.kind || 'risk')"
|
|
>
|
|
<!-- 工具名 + kind 徽章 -->
|
|
<div class="popup-item-header">
|
|
<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>
|
|
<span v-else class="popup-item-tag popup-item-tag--risk">
|
|
{{ $t('aiChat.popupTagRisk') }}
|
|
</span>
|
|
</div>
|
|
<!-- 风险说明(后端 build_approval_reason 已含工具/参数/risk 描述) -->
|
|
<div v-if="tc.reason" class="popup-item-reason">⚠ {{ tc.reason }}</div>
|
|
<!-- H1 修复:审批挂起时长提示(对齐 ToolCard: >2min 橙色 / >5min 红色脉冲)。
|
|
后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝,浮窗原本无计时,用户误以为还有时间。
|
|
基于审批到达时间(popup 首次见到该 tc.id 的时刻)计时,与 ToolCard 同款语义同款样式。 -->
|
|
<div v-if="waitSecsFor(tc.id) > 0" class="popup-item-wait" :class="'is-' + waitLevelOf(waitSecsFor(tc.id))">
|
|
{{ $t('aiTool.waitTime', { time: formatWaitTime(waitSecsFor(tc.id)) }) }}
|
|
</div>
|
|
<!-- path 类:目录提示 -->
|
|
<div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir">
|
|
📁 {{ tc.dir }}
|
|
</div>
|
|
<!-- 操作按钮组:path 类显 once/session/always/deny 四选项(与 ToolCard/DirAuthDialog 同款);
|
|
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 aria-hidden="true" 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') }}<span class="popup-btn-key">1</span>
|
|
</button>
|
|
<button
|
|
class="popup-btn popup-btn--session"
|
|
:disabled="processingId === tc.id"
|
|
@click.stop="onApproveSession(tc)"
|
|
>
|
|
<svg aria-hidden="true" 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.dirAuthSession') }}
|
|
</button>
|
|
<button
|
|
class="popup-btn popup-btn--always"
|
|
:disabled="processingId === tc.id"
|
|
@click.stop="onApproveAlways(tc)"
|
|
>
|
|
<svg aria-hidden="true" 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') }}<span class="popup-btn-key">2</span>
|
|
</button>
|
|
<button
|
|
class="popup-btn popup-btn--reject"
|
|
:disabled="processingId === tc.id"
|
|
@click.stop="onReject(tc)"
|
|
>
|
|
<svg aria-hidden="true" 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') }}<span class="popup-btn-key">3</span>
|
|
</button>
|
|
</div>
|
|
<div v-else class="popup-item-actions">
|
|
<button
|
|
class="popup-btn popup-btn--approve"
|
|
:disabled="processingId === tc.id"
|
|
@click.stop="onApprove(tc)"
|
|
>
|
|
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
|
|
<svg aria-hidden="true" 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('aiTool.approve') }}<span class="popup-btn-key">1</span>
|
|
</button>
|
|
<button
|
|
class="popup-btn popup-btn--reject"
|
|
:disabled="processingId === tc.id"
|
|
@click.stop="onReject(tc)"
|
|
>
|
|
<svg aria-hidden="true" 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('aiTool.reject') }}<span class="popup-btn-key">3</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 底部:打开审批面板(聚焦主窗口) -->
|
|
<button class="popup-footer" @click="onActivate">
|
|
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M15 3h6v6" /><path d="M9 21H3v-6" /><path d="M21 3l-7 7" /><path d="M3 21l7-7" />
|
|
</svg>
|
|
{{ $t('aiChat.popupOpenPanel') }}
|
|
</button>
|
|
|
|
<!-- P1-e-d:审批失败 toast(窗口内自包含 useToast;独立 WebviewWindow 无根 toast,自管渲染) -->
|
|
<Transition name="ai-toast">
|
|
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
|
|
</Transition>
|
|
|
|
<!-- M5:path 类 deny 二次确认(拒绝不可逆,与 ToolCard 同款;独立窗口本地 ConfirmDialog,
|
|
不复用主窗口 useConfirm reactive——跨 WebviewWindow JS 上下文隔离) -->
|
|
<ConfirmDialog
|
|
:visible="confirmState.visible"
|
|
:msg="confirmState.msg"
|
|
:danger-label="$t('common.confirm')"
|
|
@result="answerConfirm"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
|
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
|
|
import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
|
|
import { dispatchApprovalIPC, isHighRiskTool, highRiskConfirmText } from '@/composables/ai/useToolApproval'
|
|
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
|
|
import { formatWaitTime, waitLevelOf } from '@/utils/time'
|
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
|
import { useToast } from '@/composables/useToast'
|
|
import type { AiToolCallInfo } from '@/api/types'
|
|
|
|
const { t } = useI18n()
|
|
|
|
/** 主窗口推送的快照(唯一真相源,本组件不监听 AI 事件) */
|
|
const approvals = ref<AiToolCallInfo[]>([])
|
|
/** 操作中 id(防抖 + 按钮加载态) */
|
|
const processingId = ref<string>('')
|
|
// P1-e-d: 审批失败 toast——独立 WebviewWindow 无共享组件树,窗口内自包含 useToast
|
|
// (不走跨窗口 emit 中转,失败反馈一定显示在用户当前关注的浮窗内)
|
|
const { toast, showToast } = useToast()
|
|
/** 拖动态(样式反馈) */
|
|
const isDragging = ref(false)
|
|
|
|
// ── M5/V4:浮窗本地二次确认(独立窗口本地 confirm;与主窗口 JS 上下文隔离,不复用 useConfirm)──
|
|
// M5:path 类 deny(拒绝不可逆——工具返 Err、loop 续跑不再重试)。
|
|
// V4:High 风险工具 risk 批准前置确认(对齐 ToolCard 安全语义:注意力分散场景数字键「1」
|
|
// 单键放行 run_command/delete_file 太危险)。action 记录「待确认动作」供 answerConfirm 分派。
|
|
const confirmState = reactive<{ visible: boolean; msg: string; tc: AiToolCallInfo | null; action: 'deny' | 'approve' }>({
|
|
visible: false,
|
|
msg: '',
|
|
tc: null,
|
|
action: 'deny',
|
|
})
|
|
function answerConfirm(ok: boolean) {
|
|
const tc = confirmState.tc
|
|
const action = confirmState.action
|
|
confirmState.visible = false
|
|
confirmState.tc = null
|
|
if (ok && tc) {
|
|
if (action === 'approve') void runApproval(tc, true)
|
|
else void runApproval(tc, false, 'deny')
|
|
}
|
|
}
|
|
|
|
// ── H1:审批挂起时长计时(对齐 ToolCard useToolApproval 的 waitSecs/waitLevel)──
|
|
// 浮窗独立于主窗口,不直接复用 useToolApproval(那个绑定单 tc + 完整状态机 emit/防抖,
|
|
// 浮窗已用 processingId/runApproval 自管)。这里基于「到达时间」(popup 首次见到 tc.id 的时刻)
|
|
// 每秒刷新。格式化(formatWaitTime)与分级阈值(waitLevelOf,120s long / 300s urgent)
|
|
// 收敛至 @/utils/time,与 ToolCard 同源。
|
|
/** tc.id → 到达时刻(ms)。审批首次出现在 approvals 即记时;移除即清。 */
|
|
const arrivalTimes = reactive(new Map<string, number>())
|
|
/** tc.id → 已等待秒数(每秒 tick 更新)。 */
|
|
const waitSecsMap = reactive(new Map<string, number>())
|
|
let _waitTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
/** 取某条审批已等待秒数(无记录返回 0) */
|
|
function waitSecsFor(id: string): number {
|
|
return waitSecsMap.get(id) ?? 0
|
|
}
|
|
/** 每秒刷新所有挂起审批的 waitSecs(单 timer 驱动多 tc,避免每条一个 interval) */
|
|
function _tickWaitSecs() {
|
|
const now = Date.now()
|
|
for (const [id, t0] of arrivalTimes.entries()) {
|
|
waitSecsMap.set(id, Math.floor((now - t0) / 1000))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 同步到达时间到当前 approvals 快照。
|
|
* - 新出现的 id(popup 首次见到该审批)→ 记 Date.now() 起算。
|
|
* - 已不在快照的 id(被处理 / 后端清掉)→ 清 arrival + waitSecs,防 Map 无界增长 + 防幽灵计时。
|
|
* - 已记录的 id 保留原时刻(快照重推不影响计时,审批真实挂起时长不被重置)。
|
|
*/
|
|
function _syncArrivalTimes(list: AiToolCallInfo[]) {
|
|
const seen = new Set<string>()
|
|
for (const tc of list) {
|
|
seen.add(tc.id)
|
|
if (!arrivalTimes.has(tc.id)) {
|
|
arrivalTimes.set(tc.id, Date.now())
|
|
waitSecsMap.set(tc.id, 0)
|
|
}
|
|
}
|
|
for (const id of Array.from(arrivalTimes.keys())) {
|
|
if (!seen.has(id)) {
|
|
arrivalTimes.delete(id)
|
|
waitSecsMap.delete(id)
|
|
}
|
|
}
|
|
}
|
|
|
|
let _unlistenUpdate: UnlistenFn | null = null
|
|
let _positioned = false
|
|
/** close() 后窗口仍未销毁则强制 destroy() 的阈值(closeWithFallback 使用) */
|
|
const CLOSE_TIMEOUT_MS = 1500
|
|
|
|
/** Esc 键关浮窗(键盘可达性)。模块级命名函数,onBeforeUnmount 据此精确移除同一引用。
|
|
* 仅响应 Esc(KeyDown),其他键透传(不拦截 Tab/Enter 等原生 button 导航)。
|
|
*
|
|
* 7c:数字键快捷审批(对列表顶部第一条生效,多审批场景用户应逐条点或用方向键导航)。
|
|
* - path 类:1=once / 2=always / 3=deny(deny 仍走二次确认,与按钮同款不可逆守卫)
|
|
* - risk 类:1=approve / 3=reject(2 仅 path 类有 always,risk 类忽略 '2')
|
|
* 守卫:
|
|
* - 确认对话框打开时(confirmState.visible)禁用快捷键,避免与确认按钮的 Enter/Esc 冲突
|
|
* - 处理中(processingId)时忽略,防重复触发
|
|
* - 输入元素聚焦时不拦截(input/textarea/contenteditable 场景,本浮窗暂无 input 但防御)
|
|
* - Ctrl/Meta/Alt 组合不拦截(避免劫持浏览器/系统快捷键) */
|
|
function _onKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
void onClose()
|
|
return
|
|
}
|
|
// 7c:数字键快捷审批
|
|
if (e.key === '1' || e.key === '2' || e.key === '3') {
|
|
// 组合键(Ctrl+1 等)不拦截;确认对话框打开时不拦截;处理中不拦截
|
|
if (e.ctrlKey || e.metaKey || e.altKey) return
|
|
if (confirmState.visible) return
|
|
if (processingId.value) return
|
|
// 输入元素聚焦时不拦截(防御:本浮窗无 input,但 ConfirmDialog 未来或有)
|
|
const tgt = e.target as HTMLElement | null
|
|
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return
|
|
const first = approvals.value[0]
|
|
if (!first) return
|
|
e.preventDefault()
|
|
if (e.key === '1') {
|
|
// 1 = once(path) / approve(risk)
|
|
if (first.kind === 'path') void onApproveOnce(first)
|
|
else void onApprove(first)
|
|
} else if (e.key === '2') {
|
|
// 2 = always(仅 path 类;risk 类无 always,忽略)
|
|
if (first.kind === 'path') void onApproveAlways(first)
|
|
} else {
|
|
// 3 = deny(path) / reject(risk)
|
|
void onReject(first)
|
|
}
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// 首屏定位到屏幕右上角(只定位一次,后续保留用户拖动后位置)
|
|
if (!_positioned) {
|
|
_positioned = true
|
|
try {
|
|
const { currentMonitor } = await import('@tauri-apps/api/window')
|
|
const { LogicalPosition } = await import('@tauri-apps/api/dpi')
|
|
const mon = await currentMonitor()
|
|
if (mon) {
|
|
const win = getCurrentWebviewWindow()
|
|
const w = 380
|
|
// 逻辑坐标(mon.size 是物理像素,scaleFactor 转逻辑);仅用 X 定位右侧,Y 用固定顶部 16px
|
|
const scale = mon.scaleFactor || 1
|
|
const screenW = mon.size.width / scale
|
|
const x = screenW - w - 16
|
|
const y = 16 + (mon.position.y / scale)
|
|
await win.setPosition(new LogicalPosition(x, y))
|
|
}
|
|
} catch { /* 定位失败不影响功能 */ }
|
|
}
|
|
|
|
// 监听主窗口推送的 pending 快照
|
|
try {
|
|
_unlistenUpdate = await listen<{ approvals: AiToolCallInfo[] }>(
|
|
ApprovalPopupEvents.EVT_UPDATE,
|
|
(e) => {
|
|
const next = e.payload?.approvals || []
|
|
approvals.value = next
|
|
// H1:同步到达时间(新 id 记时;消失 id 清时 + 清秒数,避免 Map 无界增长)
|
|
_syncArrivalTimes(next)
|
|
// BUG2 修复:主窗口推空快照(所有 pending 已清)→ 立即关浮窗,避免停留在空态转圈
|
|
void maybeAutoClose()
|
|
},
|
|
)
|
|
} catch (e) {
|
|
console.error('[ApprovalPopup] 监听 update 事件失败:', e)
|
|
}
|
|
|
|
// 主动拉一次数据(主窗口可能在浮窗 listen 之前 emit,本次拉取兜底)
|
|
try {
|
|
const { emit } = await import('@tauri-apps/api/event')
|
|
// 用 request-reply 太重,直接 emit 一个轻量事件让主窗口重推快照
|
|
await emit('approval-popup-ready', {})
|
|
} catch { /* ignore */ }
|
|
|
|
// Esc 快捷键关浮窗(键盘可达性:无需鼠标即可关)。Tab/Enter 原生 button 已支持,不另加。
|
|
// _onKeyDown 是模块级引用,onBeforeUnmount 据此 removeEventListener(保证移除的是同一引用)
|
|
window.addEventListener('keydown', _onKeyDown)
|
|
|
|
// H1:启动挂起时长计时(每秒刷新 waitSecsMap)。单 timer 驱动所有 tc,
|
|
// onBeforeUnmount 清理。立即 tick 一次让首屏(若已有 approvals)马上显示。
|
|
_tickWaitSecs()
|
|
if (_waitTimer) clearInterval(_waitTimer)
|
|
_waitTimer = setInterval(_tickWaitSecs, 1000)
|
|
|
|
// 弹出即空态自检:创建竞态下浮窗可能收到已过期的空快照(主窗口 openOrUpdate 推快照时
|
|
// 列表恰好已清空),此后不会再有任何 EVT_UPDATE → 无人触发关闭,空态「正在关闭…」永久残留。
|
|
// mount 2s 后仍为空则主动走 maybeAutoClose 兜底关窗(正常有数据时此自检无操作)。
|
|
setTimeout(() => {
|
|
if (approvals.value.length === 0) void maybeAutoClose()
|
|
}, 2000)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
_unlistenUpdate?.()
|
|
_unlistenUpdate = null
|
|
// 清 Esc listener(防组件卸载后仍监听 window 致内存泄漏/重复触发)
|
|
window.removeEventListener('keydown', _onKeyDown)
|
|
// H1:清挂起计时器(防卸载后仍 tick 致空 Map 操作/内存泄漏)
|
|
if (_waitTimer) { clearInterval(_waitTimer); _waitTimer = null }
|
|
})
|
|
|
|
/** 关窗统一实现:close 先行(优雅),超时/失败兜底 destroy()(强制销毁)。
|
|
* 根因修复(BUG3+):原实现仅 await getCurrentWebviewWindow().close(),而 Tauri 2.x 的
|
|
* close() 触发 closeRequested(非强制),若原生窗口因任意原因未及时销毁(已知偶发),
|
|
* Vue 组件保持挂载 → 浮窗内空态 hint「正在关闭…」永久可见,UI 卡死。
|
|
* 判定依据:close() 只是派发关闭消息即 resolve,即使关闭被原生层 prevent_close 拦截
|
|
* (窗口仅隐藏未销毁),promise 照样成功 → 不能以 resolve 作为「窗口已关」依据,
|
|
* 以 once('tauri://destroyed') 置位 destroyed 标志为准,超时触发时若尚未销毁才 destroy。
|
|
* - 兜底1:close() 失败 → 未销毁则立即 destroy() 强制销毁
|
|
* - 兜底2:close() resolve 后 1.5s 仍未销毁 → destroy() 强制销毁(超时定时器)
|
|
* - 兜底3:destroy() 也失败 → 控制台报错(不再静默吞掉,便于排查)
|
|
* 兜底定时器不取消:并发调用(onClose 与 maybeAutoClose)各自持有 timer,
|
|
* destroy 幂等无害,以 destroyed 事件兜住重复强制销毁。 */
|
|
async function closeWithFallback(): Promise<void> {
|
|
const win = getCurrentWebviewWindow()
|
|
// 以 destroyed 事件为「窗口真正销毁」的判定依据(close() resolve≠销毁,见上)
|
|
let destroyed = false
|
|
void win.once('tauri://destroyed', () => { destroyed = true })
|
|
// 超时兜底:close 后窗口迟迟未销毁则强制 destroy
|
|
setTimeout(() => {
|
|
if (destroyed) return
|
|
console.warn('[ApprovalPopup] close() 后窗口未销毁,降级 destroy()')
|
|
win.destroy().catch((e: unknown) => {
|
|
console.error('[ApprovalPopup] destroy() 兜底也失败:', e)
|
|
})
|
|
}, CLOSE_TIMEOUT_MS)
|
|
try {
|
|
await win.close()
|
|
// close() resolve 不取消兜底定时器:被 prevent_close 时 promise 照样成功而窗口未必销毁,
|
|
// 是否强制 destroy 交给 destroyed 标志与超时判定(见上)
|
|
} catch (e) {
|
|
// close 失败:未销毁则降级 destroy()
|
|
if (destroyed) return
|
|
console.warn('[ApprovalPopup] close() 失败,降级 destroy():', e)
|
|
try {
|
|
await win.destroy()
|
|
} catch (e2) {
|
|
console.error('[ApprovalPopup] destroy() 兜底也失败:', e2)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** path 类:本次会话授权(decision='once') */
|
|
async function onApproveOnce(tc: AiToolCallInfo) {
|
|
await runApproval(tc, false, 'once')
|
|
}
|
|
/** M4:path 类:当前会话授权(decision='session',对齐 ToolCard/DirAuthDialog 四按钮) */
|
|
async function onApproveSession(tc: AiToolCallInfo) {
|
|
await runApproval(tc, false, 'session')
|
|
}
|
|
/** path 类:写持久白名单(decision='always') */
|
|
async function onApproveAlways(tc: AiToolCallInfo) {
|
|
await runApproval(tc, false, 'always')
|
|
}
|
|
/** risk 类:批准(approved=true)。V4:High 风险工具先二次确认(按钮与数字键 1 同走本入口,
|
|
* 确认框打开时数字键守卫生效,不绕确认;拒绝仍按 M5 原逻辑)。 */
|
|
async function onApprove(tc: AiToolCallInfo) {
|
|
if (isHighRiskTool(tc.name)) {
|
|
confirmState.msg = highRiskConfirmText(tc.name)
|
|
confirmState.tc = tc
|
|
confirmState.action = 'approve'
|
|
confirmState.visible = true
|
|
return
|
|
}
|
|
await runApproval(tc, true)
|
|
}
|
|
/** path 类 deny / risk 类 reject(统一入口,kind 由 dispatchApprovalIPC 据 tc.kind 分派)。
|
|
* M5:path 类拒绝前二次确认(拒绝不可逆);risk reject 直接执行。 */
|
|
async function onReject(tc: AiToolCallInfo) {
|
|
if (tc.kind === 'path') {
|
|
confirmState.msg = t('aiChat.dirAuthDenyConfirm')
|
|
confirmState.tc = tc
|
|
confirmState.visible = true
|
|
return
|
|
}
|
|
await runApproval(tc, false, undefined)
|
|
}
|
|
|
|
/** 审批执行统一壳:防抖(processingId)+ 调共享 IPC 分派 + 乐观移除 + 空列表自动关浮窗。
|
|
* IPC 分派规则(path→authorizeDir / risk→approve)抽离至 dispatchApprovalIPC,
|
|
* 与 useAiApproval.approveToolCall 同源,消除「同语义两套逻辑」漂移。 */
|
|
async function runApproval(tc: AiToolCallInfo, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
|
|
if (processingId.value === tc.id) return
|
|
processingId.value = tc.id
|
|
try {
|
|
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)
|
|
// P1-e-d:IPC 未送达给用户可见反馈(独立窗口自包含 toast;文案复用 ai.approvalNotDelivered)
|
|
showToast(t('ai.approvalNotDelivered', { error: e instanceof Error ? e.message : String(e) }), 'error')
|
|
} finally {
|
|
processingId.value = ''
|
|
}
|
|
}
|
|
|
|
/** 列表清空时自动关浮窗。
|
|
* 场景:用户处理完最后一条审批 / 主窗口推空快照(EVT_UPDATE approvals:[])。
|
|
* 修复 BUG2:原实现空列表渲染 popup-empty + 永久 popup-empty-spinner,用户看到「列表空 + 一直转」
|
|
* 误以为卡死。空 = 完成,应即时关浮窗(不等待主窗口 watch closePopup 的跨窗口往返)。 */
|
|
async function maybeAutoClose() {
|
|
if (approvals.value.length > 0) return
|
|
// 先关窗(确保用户立刻看到反馈),再 emit 清主窗口引用(顺序与 onClose 一致)
|
|
// 关窗走 closeWithFallback 兜底(close 超时/失败 → destroy 强制销毁),防「正在关闭…」永久
|
|
await closeWithFallback()
|
|
try {
|
|
const { emit } = await import('@tauri-apps/api/event')
|
|
await emit(ApprovalPopupEvents.EVT_CLOSED, {})
|
|
} catch (e) {
|
|
console.warn('[ApprovalPopup] emit EVT_CLOSED 失败:', e)
|
|
}
|
|
}
|
|
|
|
/** 点击「打开审批面板」:emit 主窗口聚焦 + togglePanel */
|
|
async function onActivate() {
|
|
try {
|
|
const { emit } = await import('@tauri-apps/api/event')
|
|
await emit(ApprovalPopupEvents.EVT_ACTIVATE, {})
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
/** 关闭浮窗(用户主动关 → emit closed 让主窗口清引用,允许下次有新审批再弹)
|
|
* 修复 BUG3:原顺序 emit→close,若 emit 跨窗口分发阻塞/异常(close 永不执行)→ 关闭按钮「无效」。
|
|
* 改为先 close(即时反馈,core:window:allow-close 已授权),再 emit 清主窗口引用;
|
|
* 即便 emit 失败,主窗口 win.once('tauri://destroyed') 也会清 _popupWin(双保险)。 */
|
|
async function onClose() {
|
|
// 关窗走 closeWithFallback 兜底(close 超时/失败 → destroy 强制销毁)
|
|
await closeWithFallback()
|
|
try {
|
|
const { emit } = await import('@tauri-apps/api/event')
|
|
await emit(ApprovalPopupEvents.EVT_CLOSED, {})
|
|
} catch (e) {
|
|
console.warn('[ApprovalPopup] emit EVT_CLOSED 失败:', e)
|
|
}
|
|
}
|
|
|
|
// 拖动: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>
|
|
|
|
<style scoped>
|
|
/* 浮窗根:无边框 + 紫色主题阴影 */
|
|
.popup-shell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
background: var(--df-bg-card);
|
|
border: 0.5px solid var(--df-accent);
|
|
border-left: 3px solid var(--df-accent);
|
|
border-radius: var(--df-radius-md);
|
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--df-accent-soft);
|
|
overflow: hidden;
|
|
user-select: none;
|
|
}
|
|
.popup-shell[data-dragging="true"] {
|
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 0 2px var(--df-accent);
|
|
}
|
|
|
|
/* 顶部拖动条 */
|
|
.popup-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 10px;
|
|
background: linear-gradient(135deg, var(--df-accent-bg), transparent);
|
|
border-bottom: 0.5px solid var(--df-border, rgba(123, 111, 240, 0.2));
|
|
cursor: move;
|
|
}
|
|
.popup-header-icon {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
background: var(--df-accent-bg);
|
|
color: var(--df-accent);
|
|
flex-shrink: 0;
|
|
}
|
|
.popup-header-badge {
|
|
position: absolute;
|
|
top: -3px;
|
|
right: -3px;
|
|
min-width: 16px;
|
|
height: 16px;
|
|
padding: 0 4px;
|
|
border-radius: 8px;
|
|
background: var(--df-danger);
|
|
color: #fff;
|
|
font-size: 9px;
|
|
font-weight: 500;
|
|
line-height: 16px;
|
|
text-align: center;
|
|
border: 1.5px solid var(--df-bg-card);
|
|
}
|
|
.popup-header-title {
|
|
flex: 1;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--df-text);
|
|
}
|
|
.popup-header-close {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 22px;
|
|
height: 22px;
|
|
border: none;
|
|
border-radius: var(--df-radius-sm);
|
|
background: transparent;
|
|
color: var(--df-text-dim);
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
|
|
}
|
|
.popup-header-close:hover {
|
|
background: var(--df-sidebar-hover);
|
|
color: var(--df-text);
|
|
}
|
|
|
|
/* 列表区(可滚动) */
|
|
.popup-body {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.popup-empty {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
min-height: 80px;
|
|
padding: 16px;
|
|
}
|
|
.popup-empty-hint {
|
|
font-size: 11px;
|
|
color: var(--df-text-dim);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* 单条审批项 */
|
|
.popup-item {
|
|
padding: 8px 10px;
|
|
border-radius: var(--df-radius-sm);
|
|
background: var(--df-sidebar-hover);
|
|
border-left: 2px solid var(--df-accent);
|
|
transition: background 0.15s var(--df-ease);
|
|
}
|
|
.popup-item--path { border-left-color: var(--df-accent); }
|
|
.popup-item--risk { border-left-color: var(--df-warning, #f0a020); }
|
|
.popup-item:hover {
|
|
background: var(--df-sidebar-active, rgba(123, 111, 240, 0.12));
|
|
}
|
|
|
|
.popup-item-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-bottom: 4px;
|
|
}
|
|
.popup-item-name {
|
|
flex: 1;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--df-text);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.popup-item-tag {
|
|
flex-shrink: 0;
|
|
padding: 1px 6px;
|
|
border-radius: 8px;
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.3px;
|
|
}
|
|
.popup-item-tag--path {
|
|
background: var(--df-accent-bg);
|
|
color: var(--df-accent);
|
|
}
|
|
.popup-item-tag--risk {
|
|
background: rgba(240, 160, 32, 0.18);
|
|
color: var(--df-warning, #f0a020);
|
|
}
|
|
|
|
.popup-item-reason {
|
|
font-size: 11px;
|
|
color: var(--df-text-dim);
|
|
line-height: 1.4;
|
|
margin-bottom: 4px;
|
|
word-break: break-word;
|
|
white-space: normal;
|
|
}
|
|
.popup-item-dir {
|
|
font-size: 10px;
|
|
color: var(--df-text-dim);
|
|
margin-bottom: 6px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* H1:审批挂起时长提示(对齐 ToolCard .ai-tool-wait-time)。
|
|
>2min 橙色脉冲 / >5min 红色更急脉冲;pulseGlow 复用 styles/global.css 全局 keyframes。
|
|
用户主要从浮窗审批,后端 APPROVAL_TIMEOUT_MS 5min 静默拒绝前给可见预警。 */
|
|
.popup-item-wait {
|
|
padding: 2px 8px;
|
|
margin-bottom: 6px;
|
|
font-size: 10px;
|
|
font-family: var(--df-font-mono);
|
|
color: var(--df-text-dim, #888);
|
|
border-radius: var(--df-radius-sm, 4px);
|
|
background: var(--df-bg-card, transparent);
|
|
}
|
|
.popup-item-wait.is-long {
|
|
color: var(--df-warning, #d97706);
|
|
animation: pulseGlow 1.5s ease-in-out infinite;
|
|
}
|
|
.popup-item-wait.is-urgent {
|
|
color: var(--df-danger, #e5484d);
|
|
animation: pulseGlow 1s ease-in-out infinite;
|
|
}
|
|
|
|
.popup-item-actions {
|
|
display: flex;
|
|
gap: 6px;
|
|
margin-top: 6px;
|
|
}
|
|
.popup-btn {
|
|
flex: 1;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 4px;
|
|
padding: 5px 8px;
|
|
border: none;
|
|
border-radius: var(--df-radius-sm);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
|
|
}
|
|
.popup-btn:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
/* 7c:数字键快捷键标记(1/2/3 对应 once/approve、always、deny/reject)。
|
|
小尺寸单字符徽章,提示键盘可达性;继承父按钮对比色但降透明度作辅助信息层。 */
|
|
.popup-btn-key {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 13px;
|
|
height: 13px;
|
|
padding: 0 3px;
|
|
margin-left: 1px;
|
|
border-radius: var(--df-radius-xs, 3px);
|
|
font-family: var(--df-font-mono);
|
|
font-size: 9px;
|
|
font-weight: 500;
|
|
line-height: 1;
|
|
background: rgba(0, 0, 0, 0.18);
|
|
opacity: 0.7;
|
|
}
|
|
.popup-btn--approve {
|
|
background: var(--df-accent);
|
|
color: #fff;
|
|
}
|
|
.popup-btn--approve:hover:not(:disabled) {
|
|
background: var(--df-accent-hover, #6558d8);
|
|
}
|
|
/* M4:session 按钮(弱填充态,介于 approve 强填充与 always 描边之间,视觉层级 once>session>always) */
|
|
.popup-btn--session {
|
|
background: var(--df-accent-bg);
|
|
color: var(--df-accent);
|
|
}
|
|
.popup-btn--session:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--df-accent) 30%, transparent);
|
|
}
|
|
.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);
|
|
border: 0.5px solid var(--df-border, rgba(255, 255, 255, 0.1));
|
|
}
|
|
.popup-btn--reject:hover:not(:disabled) {
|
|
background: rgba(255, 80, 80, 0.15);
|
|
color: var(--df-danger, #e5484d);
|
|
}
|
|
.popup-btn-spinner {
|
|
width: 10px;
|
|
height: 10px;
|
|
border: 1.5px solid currentColor;
|
|
border-top-color: transparent;
|
|
border-radius: 50%;
|
|
animation: df-spin 0.6s linear infinite;
|
|
display: inline-block;
|
|
}
|
|
|
|
/* 底部:打开审批面板 */
|
|
.popup-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 10px;
|
|
border: none;
|
|
border-top: 0.5px solid var(--df-border, rgba(123, 111, 240, 0.2));
|
|
background: var(--df-accent-bg);
|
|
color: var(--df-accent);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
|
|
}
|
|
.popup-footer:hover {
|
|
background: var(--df-accent);
|
|
color: #fff;
|
|
}
|
|
|
|
@keyframes df-spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* P1-e-d:审批失败 toast(自包含 useToast;独立窗口无根 toast 自管渲染。fixed 相对本窗口视口,
|
|
top 让出 header,不遮挡审批按钮区。样式对齐 AiChat .ai-toast 同款,scoped 防冲突) */
|
|
.ai-toast {
|
|
position: fixed;
|
|
top: 44px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
padding: 6px 12px;
|
|
border-radius: var(--df-radius);
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
z-index: 100;
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
|
max-width: 80%;
|
|
overflow-wrap: anywhere;
|
|
text-align: center;
|
|
}
|
|
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
|
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
|
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
|
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
|
|
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
|
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
|
</style>
|