/** * ToolCard 审批状态机 — 从 ToolCard.vue 抽离的审批 UI 逻辑。 * * 职责:审批按钮 loading 态、审批挂起时长显示(BUG-260624-02)、 * 审批超时兜底 toast(UX-260617-14)、approve/reject/path_auth 三选项处理、 * status 变化自动复位 + pending 计时。 * * 不含 store IPC 调用(useAiApproval 负责)和模板渲染——纯状态管理。 */ import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue' import { useI18n } from 'vue-i18n' import { useConfirm } from '@/composables/useConfirm' import type { AiToolCallInfo } from '@/api/types' /** * 审批按钮 loading 超时兜底(B-260616-08)。 * 后端回事件使 tc.status 离开 pending_approval → watch 复位。 * 兜底计时到点复位 + toast 提示(后端 IPC 应在 1~3s 内返回;30s 兜底远足超时后允许用户重试)。 * 与 stream 看门狗(130s)解耦:审批是按钮级交互,不是后台流,不需要对齐后端 120s 空闲超时。 */ const APPROVE_LOADING_TIMEOUT_MS = 30_000 /** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */ const HIGH_RISK_TOOLS = new Set([ 'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command', 'http_request', ]) export function useToolApproval(tc: Ref, emit: any) { const { t } = useI18n() const { confirmState, confirmDialog, answerConfirm } = useConfirm() // ── B-260616-08: 审批 loading 态 ── const approving = ref(false) let approvingTimer: ReturnType | null = null // ── BUG-260624-02: 审批挂起时长显示 ── const pendingSince = ref(null) const waitSecs = ref(0) let waitTimer: ReturnType | null = null function startPendingTimer() { if (pendingSince.value !== null) return pendingSince.value = Date.now() waitSecs.value = 0 if (waitTimer) clearInterval(waitTimer) waitTimer = setInterval(() => { if (pendingSince.value !== null) { waitSecs.value = Math.floor((Date.now() - pendingSince.value) / 1000) } }, 1000) } function stopPendingTimer() { if (waitTimer) { clearInterval(waitTimer); waitTimer = null } pendingSince.value = null waitSecs.value = 0 } function formatWaitTime(secs: number): string { const m = Math.floor(secs / 60) const s = secs % 60 return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` } const waitLevel = computed<'normal' | 'long' | 'urgent'>(() => { if (waitSecs.value >= 300) return 'urgent' // 5分钟 if (waitSecs.value >= 120) return 'long' // 2分钟 return 'normal' }) // ── UX-260617-14: 审批超时兜底 toast ── const approveToast = reactive({ visible: false, msg: '' }) let _approveToastTimer: ReturnType | null = null function showApproveTimeoutToast(): void { approveToast.msg = t('aiTool.approveLoadingTimeout') approveToast.visible = true if (_approveToastTimer) clearTimeout(_approveToastTimer) _approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000) } /** AE-2025-05:High 风险工具的二次确认文案(按操作类型) */ function highRiskConfirmMsg(name: string): string { if (name === 'run_command') return t('aiTool.confirmHighExec') if (name === 'http_request') return t('aiTool.confirmHighHttp') if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') { return t('aiTool.confirmHighDelete') } return t('aiTool.confirmHighGeneric') } /** * 批准按钮处理(risk 类专用)。 * AE-2025-05:High 风险工具 → 先二次确认(useConfirm 弹层)。 * loading + 超时兜底复用 B-260616-08 机制。 */ async function onApprove() { if (approving.value) return // 防重入 // AE-2025-05:High 风险 → 先二次确认 if (HIGH_RISK_TOOLS.has(tc.value.name)) { const ok = await confirmDialog(highRiskConfirmMsg(tc.value.name)) if (!ok) return } approving.value = true approvingTimer = setTimeout(() => { approving.value = false approvingTimer = null console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id) showApproveTimeoutToast() }, APPROVE_LOADING_TIMEOUT_MS) emit('approve', { id: tc.value.id, approved: true }) } /** * 拒绝按钮处理(risk 类专用)。 * loading + 超时兜底复用 B-260616-08 机制。拒绝不做 High 风险二次确认(无害,加确认反增摩擦)。 */ async function onReject() { if (approving.value) return // 防重入 approving.value = true approvingTimer = setTimeout(() => { approving.value = false approvingTimer = null console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id) showApproveTimeoutToast() }, APPROVE_LOADING_TIMEOUT_MS) emit('approve', { id: tc.value.id, approved: false }) } /** * path_auth 审批链阶段3b:path 类「仅本次会话授权」处理。 * 与 onApprove 同款 loading + 超时兜底,emit 带 decision='once'。 * path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,三选项本身就是用户明示决策)。 */ async function onAuthorizeOnce() { if (approving.value) return approving.value = true approvingTimer = setTimeout(() => { approving.value = false approvingTimer = null console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id) showApproveTimeoutToast() }, APPROVE_LOADING_TIMEOUT_MS) emit('approve', { id: tc.value.id, approved: false, decision: 'once' }) } /** * path_auth 审批链阶段3b:path 类「写持久白名单」处理。 * 同 onAuthorizeOnce,decision='always'。 */ async function onAuthorizeAlways() { if (approving.value) return approving.value = true approvingTimer = setTimeout(() => { approving.value = false approvingTimer = null console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id) showApproveTimeoutToast() }, APPROVE_LOADING_TIMEOUT_MS) emit('approve', { id: tc.value.id, approved: false, decision: 'always' }) } /** * path_auth 审批链阶段3b:path 类「拒绝」处理。 * 同 onAuthorizeOnce,decision='deny'。 */ async function onDeny() { if (approving.value) return approving.value = true approvingTimer = setTimeout(() => { approving.value = false approvingTimer = null console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id) showApproveTimeoutToast() }, APPROVE_LOADING_TIMEOUT_MS) emit('approve', { id: tc.value.id, approved: false, decision: 'deny' }) } // status 离开 pending_approval → 复位 approving(后端回执/转态时)。 watch(() => tc.value.status, (s) => { if (s !== 'pending_approval') { approving.value = false if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null } stopPendingTimer() } else { startPendingTimer() } }, { immediate: true }) onBeforeUnmount(() => { if (approvingTimer) clearTimeout(approvingTimer) if (_approveToastTimer) clearTimeout(_approveToastTimer) stopPendingTimer() }) return { approving, approveToast, waitSecs, waitLevel, formatWaitTime, onApprove, onReject, onAuthorizeOnce, onAuthorizeAlways, onDeny, // 供模板 ConfirmDialog 绑定 confirmState, answerConfirm, } }