新增: 审批系统级浮窗(WebviewWindow 自定义样式 + 内容/直接操作 + 点击回审批)
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
<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">
|
||||
<span class="popup-empty-spinner"></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 || []
|
||||
},
|
||||
)
|
||||
} 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)
|
||||
} 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)
|
||||
} catch (e) {
|
||||
console.error('[ApprovalPopup] 拒绝失败:', e)
|
||||
} finally {
|
||||
processingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击「打开审批面板」:emit 主窗口聚焦 + togglePanel */
|
||||
async function onActivate() {
|
||||
try {
|
||||
const { emit } = await import('@tauri-apps/api/event')
|
||||
await emit(ApprovalPopupEvents.EVT_ACTIVATE, {})
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 关闭浮窗(用户主动关 → emit closed 让主窗口清引用,允许下次有新审批再弹) */
|
||||
async function onClose() {
|
||||
try {
|
||||
const { emit } = await import('@tauri-apps/api/event')
|
||||
await emit(ApprovalPopupEvents.EVT_CLOSED, {})
|
||||
await getCurrentWebviewWindow().close()
|
||||
} 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;
|
||||
flex: 1;
|
||||
min-height: 80px;
|
||||
}
|
||||
.popup-empty-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--df-border, #444);
|
||||
border-top-color: var(--df-accent);
|
||||
border-radius: 50%;
|
||||
animation: df-spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 单条审批项 */
|
||||
.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>
|
||||
Reference in New Issue
Block a user