Files
DevFlow/src/views/ApprovalPopup.vue
T

495 lines
16 KiB
Vue

<template>
<!-- ═══ 系统级审批提示浮窗(独立 WebviewWindow 内容)═══
设计:
- 无边框(decorations:false) + alwaysOnTop + skipTaskbar 系统级可见
- 紫色主题自定义样式( OS notification 默认)
- 哑渲染器:监听主窗口推送的 approval-popup-update 事件,不重复监听 AI 事件
- 操作按钮直调 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 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 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">{{ displayName(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>
<!-- path :目录提示 -->
<div v-if="tc.kind === 'path' && tc.dir" class="popup-item-dir">
📁 {{ tc.dir }}
</div>
<!-- 操作按钮组 -->
<div 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 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') }}
</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('aiTool.reject') }}
</button>
</div>
</div>
</div>
<!-- 底部:打开审批面板(聚焦主窗口) -->
<button class="popup-footer" @click="onActivate">
<svg 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>
</div>
</template>
<script setup lang="ts">
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 { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
import type { AiToolCallInfo } from '@/api/types'
/** 主窗口推送的快照(唯一真相源,本组件不监听 AI 事件) */
const approvals = ref<AiToolCallInfo[]>([])
/** 操作中 id(防抖 + 按钮加载态) */
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
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) => {
approvals.value = e.payload?.approvals || []
// 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 */ }
})
onBeforeUnmount(() => {
_unlistenUpdate?.()
_unlistenUpdate = null
})
/** 批准:risk 类 approve(true),path 类 authorizeDir('once') */
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 = ''
}
}
/** 拒绝:risk 类 approve(false),path 类 authorizeDir('deny') */
async function onReject(tc: AiToolCallInfo) {
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)
}
approvals.value = approvals.value.filter(a => a.id !== tc.id)
await maybeAutoClose()
} catch (e) {
console.error('[ApprovalPopup] 拒绝失败:', e)
} finally {
processingId.value = ''
}
}
/** 列表清空时自动关浮窗。
* 场景:用户处理完最后一条审批 / 主窗口推空快照(EVT_UPDATE approvals:[])。
* 修复 BUG2:原实现空列表渲染 popup-empty + 永久 popup-empty-spinner,用户看到「列表空 + 一直转」
* 误以为卡死。空 = 完成,应即时关浮窗(不等待主窗口 watch closePopup 的跨窗口往返)。 */
async function maybeAutoClose() {
if (approvals.value.length > 0) return
try {
const { emit } = await import('@tauri-apps/api/event')
// 先关窗(确保用户立刻看到反馈),再 emit 清主窗口引用(顺序与 onClose 一致)
await getCurrentWebviewWindow().close()
await emit(ApprovalPopupEvents.EVT_CLOSED, {})
} catch { /* ignore */ }
}
/** 点击「打开审批面板」: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() {
try {
await getCurrentWebviewWindow().close()
} catch { /* ignore */ }
try {
const { emit } = await import('@tauri-apps/api/event')
await emit(ApprovalPopupEvents.EVT_CLOSED, {})
} catch { /* ignore */ }
}
// 拖动样式反馈(实际拖动由 data-tauri-drag-region + Tauri dataDrag 守卫处理)
function onDragStart() { isDragging.value = true }
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 rgba(123, 111, 240, 0.2);
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: 700;
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: all 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: all 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;
}
.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: all 0.15s var(--df-ease);
}
.popup-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.popup-btn--approve {
background: var(--df-accent);
color: #fff;
}
.popup-btn--approve:hover:not(:disabled) {
background: var(--df-accent-hover, #6558d8);
}
.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: all 0.15s var(--df-ease);
}
.popup-footer:hover {
background: var(--df-accent);
color: #fff;
}
@keyframes df-spin {
to { transform: rotate(360deg); }
}
</style>