feat(web): poll stage ready notifications to refresh advance banner.

Poll project notifications every 15s and reload readiness when stage_ready_to_advance arrives.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-12 03:14:30 +00:00
co-authored by Cursor
parent b41bfade53
commit a484c808d3
4 changed files with 79 additions and 2 deletions
@@ -94,7 +94,8 @@ ok chat-agent-system/internal/server 0.792s (TestProjectWorkflow*)
- **2026-09-12 续(顺序推荐执行)**:
1. ✅ **PG 迁移 + 集成实测**(`00017` 已升至 v17;`TestPG_ActivationAndStageAdvance` PASS)
2. ✅ **阶段级 ABAC MVP**(文档 list/content 按 active 阶段 + 三权过滤;`TestResolveStageAccess_ActiveStageDutyOnly` PASS)
3. ⏳ **Phase 4 通知**(文档批准后自动刷新 readiness、Outbox、聊天/SSE 全面接 ABAC)
3. ✅ **Phase 4 MVP 通知**(`00018` Outbox + 去重通知;文档批准联动 readiness;前端 15s 轮询刷新推进卡片)
4. ⏳ **Phase 4 续**(聊天/搜索/SSE 全面接 ABAC;24h 提醒升级;真实 Agent E2E)
### 顺序影响说明
@@ -105,3 +106,11 @@ ok chat-agent-system/internal/server 0.792s (TestProjectWorkflow*)
| 3 | Outbox/自动刷新 | 稳定 stage gate + ABAC | 通知推送后仍可能越权读文档,验收失真 |
**推荐路径**:先固化数据面(迁移实测)→ 再补安全门禁(ABAC)→ 最后做体验增强(通知)。
### Phase 4 MVP 实测(2026-09-12)
| 用例 | 判定 | 说明 |
|------|------|------|
| `TestHandleDocumentApproved_StageReady` | PASS | 文档批准后同步 task 状态并评估 stage ready |
| `TestPG_EmitStageReadyDedupe` | PASS | `(stage_instance_id, readiness_revision)` 去重,重复 emit 不产生第二条通知 |
| `TestPG_ActivationAndStageAdvance`(含 notify 段) | PASS | 批准 → HandleDocumentApproved → EmitStageReadyToAdvance 全链路 |
+21
View File
@@ -5,6 +5,7 @@ import {
ActivateResult,
StageReadiness,
AdvanceStageResult,
ProjectNotification,
} from '../types/projectSettings';
import { getBearerAuthHeaders } from './authHeaders';
@@ -109,6 +110,26 @@ export async function fetchStageReadiness(
return res.json();
}
export async function fetchProjectNotifications(
projectId: number,
sinceId?: number
): Promise<ProjectNotification[]> {
const params = new URLSearchParams();
if (sinceId && sinceId > 0) params.set('since_id', String(sinceId));
const qs = params.toString();
const res = await fetch(
`${API_BASE}/api/v1/projects/${projectId}/notifications${qs ? `?${qs}` : ''}`,
{ headers: getBearerAuthHeaders() }
);
if (!res.ok) {
if (res.status === 503) return [];
const text = await res.text();
throw new Error(parseError(text) || `获取项目通知失败: ${res.status}`);
}
const data = await res.json();
return Array.isArray(data.items) ? data.items : [];
}
export async function advanceStage(
instanceId: number,
stageKey: string,
@@ -1,6 +1,11 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ArrowRight, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react';
import { fetchProjectSettings, fetchStageReadiness, advanceStage } from '../../api/projectSettings';
import {
fetchProjectSettings,
fetchStageReadiness,
fetchProjectNotifications,
advanceStage,
} from '../../api/projectSettings';
import { StageReadiness } from '../../types/projectSettings';
interface StageAdvanceBannerProps {
@@ -19,6 +24,7 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
const [error, setError] = useState<string | null>(null);
const [instanceId, setInstanceId] = useState<number | null>(null);
const [readiness, setReadiness] = useState<StageReadiness | null>(null);
const [lastNotificationId, setLastNotificationId] = useState(0);
const load = useCallback(async () => {
setLoading(true);
@@ -47,6 +53,30 @@ export const StageAdvanceBanner: React.FC<StageAdvanceBannerProps> = ({
load();
}, [load]);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const items = await fetchProjectNotifications(projectId, lastNotificationId);
if (cancelled || items.length === 0) return;
const stageReady = items.some((n) => n.type === 'stage_ready_to_advance');
const maxId = items.reduce((max, n) => Math.max(max, n.id), lastNotificationId);
setLastNotificationId(maxId);
if (stageReady) {
await load();
}
} catch {
// 通知服务未就绪时静默降级,仍依赖手动刷新
}
};
const timer = window.setInterval(poll, 15000);
poll();
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [projectId, lastNotificationId, load]);
const handleAdvance = async () => {
if (!readiness || !instanceId || !readiness.can_advance) return;
setAdvancing(true);
+17
View File
@@ -70,6 +70,23 @@ export interface ActivateResult {
active_workflow_key: string;
}
export interface ProjectNotification {
id: number;
user_id: number;
project_id: number;
type: string;
title: string;
body: string;
status: string;
created_at: string;
payload?: {
stage_key?: string;
stage_name?: string;
config_revision?: number;
workflow_instance_id?: number;
};
}
export interface StageReadiness {
instance_id: number;
stage_key: string;