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:
@@ -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 全链路 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user