diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 290a225..d38bb78 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -1,7 +1,7 @@
{
"identifier": "default",
"description": "DevFlow default permissions",
- "windows": ["main", "ai-detached", "ai-detached-*", "fe-detached-*"],
+ "windows": ["main", "ai-detached", "ai-detached-*", "fe-detached-*", "approval-popup"],
"permissions": [
"core:default",
"core:event:default",
@@ -15,6 +15,9 @@
"core:window:allow-set-size",
"core:window:allow-outer-position",
"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",
"dialog:default",
"window-state:default"
diff --git a/src/App.vue b/src/App.vue
index 7e19464..05bb427 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -34,8 +34,9 @@
⚠ {{ errorMsg }}
-
+
@@ -51,15 +52,19 @@ import AiChat from './components/AiChat.vue'
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
import ErrorBoundary from './components/ErrorBoundary.vue'
import AppLayout from './components/layout/AppLayout.vue'
+import { startApprovalPopup } from '@/composables/ai/useApprovalPopup'
import { aiApi } from '@/api'
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 appSettings = useAppSettingsStore()
-// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer 等其他分离窗口弹)
-const showApprovalOverlay = computed(() => route.path !== '/file-explorer-detached')
+// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer / approval-popup 等其他分离窗口弹)
+// approval-popup 是系统级浮窗本身,内部已显式渲染 pending 列表,再挂 ApprovalOverlay 会重复
+const showApprovalOverlay = computed(() =>
+ route.path !== '/file-explorer-detached' && route.path !== '/approval-popup'
+)
/** 用户点击审批浮层 → 激活 AI 面板/分离窗口。
* 主窗口:打开 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 () => {
// 1. 先把 SQLite 全表 KV 填进 appSettings 缓存(后续 get/useSetting 才能读到真实值)
await appSettings.loadAll()
@@ -153,6 +176,28 @@ onMounted(async () => {
} catch (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),
@@ -193,6 +238,10 @@ const knowledgeStore = useKnowledgeStore()
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
projectStore.stopDataChangedListener?.()
+ _stopApprovalPopup?.()
+ _unlistenPopupReady?.()
+ _stopApprovalPopup = null
+ _unlistenPopupReady = null
})
diff --git a/src/composables/ai/useApprovalPopup.ts b/src/composables/ai/useApprovalPopup.ts
new file mode 100644
index 0000000..2db1012
--- /dev/null
+++ b/src/composables/ai/useApprovalPopup.ts
@@ -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 }
+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,
+ 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 {
+ 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 {
+ try {
+ if (!_popupWin) return
+ await _popupWin.close()
+ _popupWin = null
+ } catch {
+ /* 浮窗已被关/不可用,忽略 */
+ }
+}
+
+export const ApprovalPopupEvents = {
+ EVT_UPDATE,
+ EVT_ACTIVATE,
+ EVT_CLOSED,
+}
diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts
index b88f75b..e17182b 100644
--- a/src/i18n/en/aiChat.ts
+++ b/src/i18n/en/aiChat.ts
@@ -52,6 +52,12 @@ export default {
approvalWaitingTitle: 'Approval required',
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 ──
clickToSwitchProvider: 'Click to switch provider',
providerNotConfigured: 'No AI provider configured. Please add one in settings.',
diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts
index 6145242..ee01e5c 100644
--- a/src/i18n/zh-CN/aiChat.ts
+++ b/src/i18n/zh-CN/aiChat.ts
@@ -52,6 +52,12 @@ export default {
approvalWaitingTitle: '有待审批请求',
approvalWaitingDesc: '点击查看并处理',
+ // ── 系统级审批浮窗(独立窗口,应用失焦时可见)──
+ popupTitle: '审批待办',
+ popupTagPath: '路径授权',
+ popupTagRisk: '风险操作',
+ popupOpenPanel: '打开审批面板',
+
// ── Provider 状态 ──
clickToSwitchProvider: '点击切换 Provider',
providerNotConfigured: '未配置 AI 提供商,请在设置中添加',
diff --git a/src/router/index.ts b/src/router/index.ts
index 2d62bad..529eff5 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -89,6 +89,13 @@ const routes = [
component: () => import('../views/FileExplorerDetached.vue'),
meta: { title: '文件浏览器' },
},
+ {
+ // 系统级审批提示浮窗(独立 WebviewWindow 内容):无边框 + alwaysOnTop + skipTaskbar
+ path: '/approval-popup',
+ name: 'ApprovalPopup',
+ component: () => import('../views/ApprovalPopup.vue'),
+ meta: { title: '审批待办' },
+ },
// catch-all:无效路由重定向首页(防白屏)
{
path: '/:pathMatch(.*)*',
diff --git a/src/views/ApprovalPopup.vue b/src/views/ApprovalPopup.vue
new file mode 100644
index 0000000..c09b87a
--- /dev/null
+++ b/src/views/ApprovalPopup.vue
@@ -0,0 +1,470 @@
+
+
+
+
+
+
+
+