新增: 后台会话系统通知与托盘运行态

- 完成/失败/需介入三类系统通知,主窗聚焦静默
- 同类通知五秒合并,用户主动停止不通知
- 托盘菜单与悬浮提示实时显运行中会话数
- 关窗遇运行会话提示后台续跑,每进程一次
- 侧栏常驻徽章显后台运行会话数
This commit is contained in:
lxy
2026-10-01 19:35:20 +08:00
parent 66c6553cdf
commit e1dd0bc3a3
13 changed files with 441 additions and 24 deletions
+31
View File
@@ -34,6 +34,12 @@
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
</transition>
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
告知会话转入后台继续生成、托盘图标可随时唤起,复用 df-toast 形态 -->
<transition name="toast">
<div v-if="trayNoticeMsg" class="df-toast df-toast--info">{{ trayNoticeMsg }}</div>
</transition>
<!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责。 -->
@@ -101,6 +107,12 @@ watch(() => projectStore.error, (err) => {
errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500)
})
// ── 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice)──
// 与错误 toast 同款 df-toast 形态,信息语义走 --info 变体底色,不与错误红混用
const trayNoticeMsg = ref('')
let trayNoticeTimer: ReturnType<typeof setTimeout> | null = null
let _unlistenTrayHideNotice: (() => void) | null = null
// ── 人工审批对话框(全局,工作流推进到 HumanNode 审批时,任意页面都应能响应)──
// 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但
// 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。
@@ -208,6 +220,20 @@ onMounted(async () => {
} catch (e) {
console.error('启动数据变更监听失败:', e)
}
// 关窗后台运行提示:主窗关闭被拦截且有会话运行时,后端 emit tray-hide-notice → 全局气泡告知。
// 包 try/catch 防 Tauri listen 失败 reject onMounted(对齐 startDataChangedListener 模式)
try {
const { listen } = await import('@tauri-apps/api/event')
_unlistenTrayHideNotice = await listen<{ running: number }>('tray-hide-notice', (e) => {
const running = e.payload?.running ?? 0
if (running <= 0) return
trayNoticeMsg.value = `${running} 个会话将继续在后台运行,托盘图标可随时唤起`
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
trayNoticeTimer = setTimeout(() => { trayNoticeMsg.value = '' }, 3500)
})
} catch (e) {
console.error('关窗后台运行提示监听失败:', e)
}
// 系统级审批浮窗:仅主窗口启动(approval-popup 自身路由不启动,避免重复 watch)。
// 设计见 useApprovalPopup.ts:主窗口 watch pendingApprovals → 弹/更新/关浮窗(单一真相源)。
if (route.path !== '/approval-popup') {
@@ -274,6 +300,9 @@ onUnmounted(() => {
_unlistenPopupReady?.()
_stopApprovalPopup = null
_unlistenPopupReady = null
_unlistenTrayHideNotice?.()
_unlistenTrayHideNotice = null
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
})
</script>
@@ -294,4 +323,6 @@ onUnmounted(() => {
}
.toast-enter-active, .toast-leave-active { transition: all 0.25s var(--df-ease); }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, 10px); }
/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */
.df-toast--info { background: var(--df-accent); }
</style>
+33 -2
View File
@@ -51,12 +51,14 @@
<!-- Footer -->
<div class="sidebar-footer">
<!-- AI 面板切换按钮 -->
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
<!-- AI 面板切换按钮(footer 常驻跨路由渲染,承担后台运行中会话的全局可见标识) -->
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="runningConvCount > 0 ? `${runningConvCount} 个会话运行中` : (collapsed ? $t('nav.aiPanel') : undefined)">
<span class="nav-link-icon ai-spark-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 3v3M21 4.5h-3M5 17v3M6.5 18.5h-3"/></svg>
</span>
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
<span v-if="!collapsed && runningConvCount > 0" class="nav-link-badge">{{ runningConvCount }}</span>
<span v-if="collapsed && runningConvCount > 0" class="ai-running-dot"></span>
<span v-if="!collapsed" class="ai-toggle-shortcut">⌘I</span>
</button>
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined">
@@ -83,6 +85,7 @@ import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useAiStore } from '@/stores/ai'
import { useKnowledgeStore } from '@/stores/knowledge'
import { useAppSettingsStore } from '@/stores/appSettings'
import { convStates } from '@/composables/ai/aiShared'
const aiStore = useAiStore()
const knowledgeStore = useKnowledgeStore()
@@ -135,6 +138,17 @@ onBeforeUnmount(() => {
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
const candidateCount = computed(() => knowledgeStore.candidateCount)
// 全局运行中会话数(AI 面板关闭或切到 Tasks/Ideas 等页面时,后台生成在 footer 常驻按钮上仍可见)。
// 真相源 convStates(reactive Map,消费后端 AiConvStateChanged,写入在 isCurrent 守卫之前故后台会话也更新),
// 非终止三态计入,与 stores/ai.ts isGenerating 桥接语义一致;computed 内遍历 Map 即建立响应式依赖
const runningConvCount = computed(() => {
let n = 0
for (const s of convStates.values()) {
if (s === 'generating' || s === 'stopping' || s === 'compressed') n++
}
return n
})
const primaryNav = [
{ path: '/dashboard', label: 'nav.overview', badge: null,
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' },
@@ -349,6 +363,23 @@ const secondaryNav = [
border-radius: var(--df-radius-sm);
}
/* 收缩态运行中标识:绿点钉在按钮右上角(展开态走 nav-link-badge 数字徽章,无空间放数字)。
脉冲范式与 ConversationSidebar 的 ai-conv-gen-dot 同源 */
.ai-running-dot {
position: absolute;
top: 2px;
right: 6px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-accent);
animation: ai-sidebar-gen-pulse 1.4s ease-in-out infinite;
}
@keyframes ai-sidebar-gen-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
/* — Footer — */
.sidebar-footer {
padding: 6px 8px;