新增: 审批系统级浮窗(WebviewWindow 自定义样式 + 内容/直接操作 + 点击回审批)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user