新增: 审批系统级浮窗(WebviewWindow 自定义样式 + 内容/直接操作 + 点击回审批)
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "DevFlow default permissions",
|
"description": "DevFlow default permissions",
|
||||||
"windows": ["main", "ai-detached", "ai-detached-*", "fe-detached-*"],
|
"windows": ["main", "ai-detached", "ai-detached-*", "fe-detached-*", "approval-popup"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:event:default",
|
"core:event:default",
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
"core:window:allow-set-size",
|
"core:window:allow-set-size",
|
||||||
"core:window:allow-outer-position",
|
"core:window:allow-outer-position",
|
||||||
"core:window:allow-inner-size",
|
"core:window:allow-inner-size",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-set-skip-taskbar",
|
||||||
|
"core:window:allow-set-decorations",
|
||||||
"core:webview:allow-create-webview-window",
|
"core:webview:allow-create-webview-window",
|
||||||
"dialog:default",
|
"dialog:default",
|
||||||
"window-state:default"
|
"window-state:default"
|
||||||
|
|||||||
+54
-5
@@ -34,8 +34,9 @@
|
|||||||
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<!-- 审批待办浮层(屏幕右上角,窗口置顶时浮于所有桌面窗口上面)。
|
<!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。
|
||||||
仅在主窗口与 ai-detached 窗口挂载(file-explorer 等其他分离窗口不挂)。 -->
|
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。
|
||||||
|
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责。 -->
|
||||||
<ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" />
|
<ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -51,15 +52,19 @@ import AiChat from './components/AiChat.vue'
|
|||||||
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
|
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
|
||||||
import ErrorBoundary from './components/ErrorBoundary.vue'
|
import ErrorBoundary from './components/ErrorBoundary.vue'
|
||||||
import AppLayout from './components/layout/AppLayout.vue'
|
import AppLayout from './components/layout/AppLayout.vue'
|
||||||
|
import { startApprovalPopup } from '@/composables/ai/useApprovalPopup'
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const isDetached = computed(() => route.path === '/ai-detached' || route.path === '/file-explorer-detached')
|
const isDetached = computed(() => route.path === '/ai-detached' || route.path === '/file-explorer-detached' || route.path === '/approval-popup')
|
||||||
const aiStore = useAiStore()
|
const aiStore = useAiStore()
|
||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer 等其他分离窗口弹)
|
// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer / approval-popup 等其他分离窗口弹)
|
||||||
const showApprovalOverlay = computed(() => route.path !== '/file-explorer-detached')
|
// approval-popup 是系统级浮窗本身,内部已显式渲染 pending 列表,再挂 ApprovalOverlay 会重复
|
||||||
|
const showApprovalOverlay = computed(() =>
|
||||||
|
route.path !== '/file-explorer-detached' && route.path !== '/approval-popup'
|
||||||
|
)
|
||||||
|
|
||||||
/** 用户点击审批浮层 → 激活 AI 面板/分离窗口。
|
/** 用户点击审批浮层 → 激活 AI 面板/分离窗口。
|
||||||
* 主窗口:打开 AI 面板;分离窗口:提升焦点(窗口本身 alwaysOnTop 已置顶)。 */
|
* 主窗口:打开 AI 面板;分离窗口:提升焦点(窗口本身 alwaysOnTop 已置顶)。 */
|
||||||
@@ -112,6 +117,24 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 系统级审批浮窗(独立窗口):仅主窗口启动(ai-detached/approval-popup 自身不启动,避免重复 watch)
|
||||||
|
let _stopApprovalPopup: (() => void) | null = null
|
||||||
|
let _unlistenPopupReady: (() => void) | null = null
|
||||||
|
async function activateForPopup() {
|
||||||
|
// 浮窗「打开审批面板」按钮 → 聚焦主窗口 + 打开 AI 面板/激活分离窗口
|
||||||
|
try {
|
||||||
|
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
const mainWin = await WebviewWindow.getByLabel('main')
|
||||||
|
if (mainWin) {
|
||||||
|
await mainWin.show()
|
||||||
|
await mainWin.setFocus()
|
||||||
|
}
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
if (!aiStore.state.detached && !aiStore.state.panelOpen) {
|
||||||
|
aiStore.togglePanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 1. 先把 SQLite 全表 KV 填进 appSettings 缓存(后续 get/useSetting 才能读到真实值)
|
// 1. 先把 SQLite 全表 KV 填进 appSettings 缓存(后续 get/useSetting 才能读到真实值)
|
||||||
await appSettings.loadAll()
|
await appSettings.loadAll()
|
||||||
@@ -153,6 +176,28 @@ onMounted(async () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('启动数据变更监听失败:', e)
|
console.error('启动数据变更监听失败:', e)
|
||||||
}
|
}
|
||||||
|
// 系统级审批浮窗:仅主窗口启动(approval-popup 自身路由不启动,避免重复 watch)。
|
||||||
|
// 设计见 useApprovalPopup.ts:主窗口 watch pendingApprovals → 弹/更新/关浮窗(单一真相源)。
|
||||||
|
if (route.path !== '/approval-popup') {
|
||||||
|
_stopApprovalPopup = startApprovalPopup(
|
||||||
|
() => aiStore.state.pendingApprovals,
|
||||||
|
activateForPopup,
|
||||||
|
)
|
||||||
|
// 浮窗 webview 挂载后会 emit 'approval-popup-ready' 请求初始快照(防主窗口在浮窗 listen 前
|
||||||
|
// emit 丢失)。此处 listen 后立即重推当前 pendingApprovals。
|
||||||
|
try {
|
||||||
|
const { listen } = await import('@tauri-apps/api/event')
|
||||||
|
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
_unlistenPopupReady = await listen('approval-popup-ready', async () => {
|
||||||
|
const popup = await WebviewWindow.getByLabel('approval-popup')
|
||||||
|
if (popup) {
|
||||||
|
await popup.emit('approval-popup-update', { approvals: aiStore.state.pendingApprovals })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ApprovalPopup] ready 监听失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/// 一次性迁移:遍历遗留的 localStorage key,若有值则导入 appSettings(SQLite),
|
/// 一次性迁移:遍历遗留的 localStorage key,若有值则导入 appSettings(SQLite),
|
||||||
@@ -193,6 +238,10 @@ const knowledgeStore = useKnowledgeStore()
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', handleKeydown)
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
projectStore.stopDataChangedListener?.()
|
projectStore.stopDataChangedListener?.()
|
||||||
|
_stopApprovalPopup?.()
|
||||||
|
_unlistenPopupReady?.()
|
||||||
|
_stopApprovalPopup = null
|
||||||
|
_unlistenPopupReady = null
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
//! 系统级审批提示浮窗(独立 WebviewWindow,应用失焦/最小化时可见)。
|
||||||
|
//!
|
||||||
|
//! 设计(4 性平衡):
|
||||||
|
//! - **机制**:主窗口(App.vue)是 pendingApprovals 唯一真相源(已有 useAiEvents 维护),
|
||||||
|
//! 浮窗是「哑渲染器」—— 主窗口通过 Tauri event 推快照给浮窗,浮窗不重复监听 AI 事件
|
||||||
|
//! (避免双 listener 竞态/状态漂移)。浮窗操作按钮直调 aiApi IPC,后端 emit → 主窗口
|
||||||
|
//! listener 更新 store → 主窗口推新快照 → 浮窗刷新,闭环。
|
||||||
|
//! - **系统级**:独立 WebviewWindow,alwaysOnTop + decorations:false + skipTaskbar,
|
||||||
|
//! 无边框自定义紫色主题(非 OS notification 默认样式)。
|
||||||
|
//! - **可靠兜底**:浮窗创建失败/不可用 → 静默降级(不影响现有 ApprovalOverlay 应用内浮层)。
|
||||||
|
//! 浮窗操作 IPC 失败 → 浮窗内 toast,不阻塞主窗口审批流。
|
||||||
|
//! - **健壮边界**:pendingApprovals 清空 → 自动关浮窗;浮窗被用户手动关 → 主窗口 unlisten + 允许下次重弹。
|
||||||
|
//!
|
||||||
|
//! 事件协议(主窗口 ↔ 浮窗):
|
||||||
|
//! - 主窗口 → 浮窗:`approval-popup-update` { approvals: AiToolCallInfo[] }
|
||||||
|
//! - 浮窗 → 主窗口:`approval-popup-activate`(聚焦主窗口 + 打开 AI 面板)
|
||||||
|
//! - 浮窗 → 主窗口:`approval-popup-closed`(用户关浮窗,主窗口清引用)
|
||||||
|
//!
|
||||||
|
//! 调用方:App.vue 主窗口 onMounted watch pendingApprovals → openOrUpdate/clear。
|
||||||
|
|
||||||
|
import { watch, type WatchSource } from 'vue'
|
||||||
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
|
/** 浮窗 label(全局唯一,与 ai-detached / fe-detached 区分) */
|
||||||
|
const POPUP_LABEL = 'approval-popup'
|
||||||
|
/** 浮窗路由 hash(URL 同 AiDetached 模式,走 hash 路由) */
|
||||||
|
const POPUP_URL_HASH = '#/approval-popup'
|
||||||
|
/** 浮窗与主窗口通信的事件名 */
|
||||||
|
const EVT_UPDATE = 'approval-popup-update'
|
||||||
|
const EVT_ACTIVATE = 'approval-popup-activate'
|
||||||
|
const EVT_CLOSED = 'approval-popup-closed'
|
||||||
|
|
||||||
|
/** 模块级:当前浮窗引用 + 是否用户手动关闭(防 watch 重复弹) */
|
||||||
|
type PopupWin = { close: () => Promise<void> }
|
||||||
|
let _popupWin: PopupWin | null = null
|
||||||
|
let _userClosed = false
|
||||||
|
let _unlistenActivate: (() => void) | null = null
|
||||||
|
let _unlistenClosed: (() => void) | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动系统级审批浮窗管理。
|
||||||
|
*
|
||||||
|
* @param source 取 pendingApprovals 的响应式源(通常是 () => store.state.pendingApprovals)
|
||||||
|
* @param onActivate 浮窗「打开审批面板」按钮回调(主窗口聚焦 + togglePanel)
|
||||||
|
* @returns cleanup 函数(onUnmounted 调用,关浮窗 + unlisten)
|
||||||
|
*/
|
||||||
|
export function startApprovalPopup(
|
||||||
|
source: WatchSource<AiToolCallInfo[]>,
|
||||||
|
onActivate: () => void,
|
||||||
|
): () => void {
|
||||||
|
// 监听浮窗 → 主窗口的 activate 事件(浮窗点「打开审批面板」)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const { listen } = await import('@tauri-apps/api/event')
|
||||||
|
_unlistenActivate = await listen(EVT_ACTIVATE, () => {
|
||||||
|
onActivate()
|
||||||
|
})
|
||||||
|
_unlistenClosed = await listen(EVT_CLOSED, () => {
|
||||||
|
// 浮窗被用户/系统关闭:清引用 + 标记用户关(下次有新审批才重弹)
|
||||||
|
_popupWin = null
|
||||||
|
_userClosed = true
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ApprovalPopup] 主窗口事件监听失败:', e)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// watch pendingApprovals → 弹/更新/关浮窗
|
||||||
|
const stop = watch(source, async (approvals) => {
|
||||||
|
// 无 pending:浮窗存在则关,不存在则不弹
|
||||||
|
if (!approvals || approvals.length === 0) {
|
||||||
|
_userClosed = false // 清空时复位,下次有新审批允许弹
|
||||||
|
await closePopup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 有 pending + 用户已手动关浮窗:不再自动弹(尊重用户决定,直到 pending 清空再复位)
|
||||||
|
if (_userClosed) return
|
||||||
|
await openOrUpdate(approvals)
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop()
|
||||||
|
_unlistenActivate?.()
|
||||||
|
_unlistenClosed?.()
|
||||||
|
_unlistenActivate = null
|
||||||
|
_unlistenClosed = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建或更新浮窗(已存在则仅推新快照) */
|
||||||
|
async function openOrUpdate(approvals: AiToolCallInfo[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
|
const existing = await WebviewWindow.getByLabel(POPUP_LABEL)
|
||||||
|
if (existing) {
|
||||||
|
// 已存在:仅推新快照(浮窗监听 EVT_UPDATE 渲染)
|
||||||
|
_popupWin = existing
|
||||||
|
await existing.emit(EVT_UPDATE, { approvals })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 不存在:创建独立无边框浮窗
|
||||||
|
const url = window.location.origin + window.location.pathname + POPUP_URL_HASH
|
||||||
|
const win = new WebviewWindow(POPUP_LABEL, {
|
||||||
|
url,
|
||||||
|
title: 'DevFlow 审批待办',
|
||||||
|
width: 380,
|
||||||
|
height: 480,
|
||||||
|
minWidth: 320,
|
||||||
|
minHeight: 200,
|
||||||
|
resizable: true,
|
||||||
|
decorations: false, // 无系统边框/标题栏(自定义紫色主题)
|
||||||
|
alwaysOnTop: true, // 系统级:浮于所有桌面窗口上面
|
||||||
|
skipTaskbar: true, // 不占任务栏(避免与主窗口双任务栏项)
|
||||||
|
focus: false, // 不抢焦点(不打断用户当前操作,仅作可见提示)
|
||||||
|
transparent: false,
|
||||||
|
center: false, // 位置由浮窗自己定位(右上角,见 ApprovalPopup.vue)
|
||||||
|
})
|
||||||
|
_popupWin = win
|
||||||
|
win.once('tauri://created', async () => {
|
||||||
|
// 创建成功后立即推首批数据(浮窗 mount 后开始 listen,需保证时序:
|
||||||
|
// 浮窗内 onMounted listen → 主窗口 emit;但创建是异步,浮窗可能尚未 listen。
|
||||||
|
// 兜底:浮窗 mount 时主动 emit 'request-update' 拉数据,主窗口响应推完整快照。
|
||||||
|
// 此处也立即推一次(若浮窗已 listen 则直接收,未 listen 则由 request-update 兜底)
|
||||||
|
try {
|
||||||
|
await win.emit(EVT_UPDATE, { approvals })
|
||||||
|
} catch { /* 浮窗未就绪,等 request-update 兜底 */ }
|
||||||
|
})
|
||||||
|
win.once('tauri://error', (e) => {
|
||||||
|
console.error('[ApprovalPopup] 浮窗创建失败:', e)
|
||||||
|
_popupWin = null
|
||||||
|
})
|
||||||
|
win.once('tauri://destroyed', () => {
|
||||||
|
_popupWin = null
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
// 静默降级:浮窗不可用不影响现有 ApprovalOverlay 应用内浮层
|
||||||
|
console.error('[ApprovalPopup] openOrUpdate 失败,降级到应用内浮层:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭浮窗(若存在) */
|
||||||
|
async function closePopup(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!_popupWin) return
|
||||||
|
await _popupWin.close()
|
||||||
|
_popupWin = null
|
||||||
|
} catch {
|
||||||
|
/* 浮窗已被关/不可用,忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApprovalPopupEvents = {
|
||||||
|
EVT_UPDATE,
|
||||||
|
EVT_ACTIVATE,
|
||||||
|
EVT_CLOSED,
|
||||||
|
}
|
||||||
@@ -52,6 +52,12 @@ export default {
|
|||||||
approvalWaitingTitle: 'Approval required',
|
approvalWaitingTitle: 'Approval required',
|
||||||
approvalWaitingDesc: 'Click to review and handle',
|
approvalWaitingDesc: 'Click to review and handle',
|
||||||
|
|
||||||
|
// ── System-level approval popup (standalone window, visible when app unfocused) ──
|
||||||
|
popupTitle: 'Approval pending',
|
||||||
|
popupTagPath: 'Path auth',
|
||||||
|
popupTagRisk: 'Risk action',
|
||||||
|
popupOpenPanel: 'Open approval panel',
|
||||||
|
|
||||||
// ── Provider status ──
|
// ── Provider status ──
|
||||||
clickToSwitchProvider: 'Click to switch provider',
|
clickToSwitchProvider: 'Click to switch provider',
|
||||||
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
|
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export default {
|
|||||||
approvalWaitingTitle: '有待审批请求',
|
approvalWaitingTitle: '有待审批请求',
|
||||||
approvalWaitingDesc: '点击查看并处理',
|
approvalWaitingDesc: '点击查看并处理',
|
||||||
|
|
||||||
|
// ── 系统级审批浮窗(独立窗口,应用失焦时可见)──
|
||||||
|
popupTitle: '审批待办',
|
||||||
|
popupTagPath: '路径授权',
|
||||||
|
popupTagRisk: '风险操作',
|
||||||
|
popupOpenPanel: '打开审批面板',
|
||||||
|
|
||||||
// ── Provider 状态 ──
|
// ── Provider 状态 ──
|
||||||
clickToSwitchProvider: '点击切换 Provider',
|
clickToSwitchProvider: '点击切换 Provider',
|
||||||
providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
|
providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ const routes = [
|
|||||||
component: () => import('../views/FileExplorerDetached.vue'),
|
component: () => import('../views/FileExplorerDetached.vue'),
|
||||||
meta: { title: '文件浏览器' },
|
meta: { title: '文件浏览器' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 系统级审批提示浮窗(独立 WebviewWindow 内容):无边框 + alwaysOnTop + skipTaskbar
|
||||||
|
path: '/approval-popup',
|
||||||
|
name: 'ApprovalPopup',
|
||||||
|
component: () => import('../views/ApprovalPopup.vue'),
|
||||||
|
meta: { title: '审批待办' },
|
||||||
|
},
|
||||||
// catch-all:无效路由重定向首页(防白屏)
|
// catch-all:无效路由重定向首页(防白屏)
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
|
|||||||
@@ -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