import { useDeferredValue, useEffect, useMemo, useState } from "react"; import { useParams, Link, useNavigate } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Plus } from "lucide-react"; import { aiPmoApi } from "@/api/aiPmo"; import { sstcApi, type AIRoutingConfig, type SSTCSignal, type StageKey } from "@/api/sstc"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ModelCapabilityMatrix } from "@/components/model-capability/ModelCapabilityMatrix"; import { BudgetControlPanel } from "@/components/budget/BudgetControlPanel"; import { SSTCGlobalHeader } from "@/components/sstc/SSTCGlobalHeader"; import { SSTCStageNav } from "@/components/sstc/SSTCStageNav"; import { SSTCWorkspace } from "@/components/sstc/SSTCWorkspace"; import { useToast } from "@/components/ui/toast"; import { formatProjectNameWithId } from "@/lib/display"; import { getApiErrorMessage } from "@/lib/apiError"; import { compactTitle } from "@/lib/title"; import { cn } from "@/lib/utils"; import CreateCharterDialog from "./components/CreateCharterDialog"; import ProjectSettingsDialog from "./components/ProjectSettingsDialog"; import { DEFAULT_SIGNAL_FILTERS, type SignalFilterState, LIVE_REFRESH_MS, dedupeWorkItemsBySemanticKey, toReadableWorkItemSummary, toReadableWorkItemTitle, } from "./helpers"; export default function ProjectBoardPage() { const SIGNAL_PAGE_SIZE = 10; const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { toast } = useToast(); const [isMobileViewport, setIsMobileViewport] = useState(() => typeof window !== "undefined" ? window.innerWidth < 768 : false ); const [activeStage, setActiveStage] = useState("signal"); const [selectedCharterId, setSelectedCharterId] = useState(null); const [createCharterOpen, setCreateCharterOpen] = useState(false); const [newCharterTitle, setNewCharterTitle] = useState(""); const [newCharterRawText, setNewCharterRawText] = useState(""); const [selectedSignalIds, setSelectedSignalIds] = useState([]); const [charterSeedSignalId, setCharterSeedSignalId] = useState(null); const [signalSearch, setSignalSearch] = useState(""); const [showAllSignalOptions, setShowAllSignalOptions] = useState(false); const [signalPage, setSignalPage] = useState(1); const [signalFilters, setSignalFilters] = useState(DEFAULT_SIGNAL_FILTERS); const [settingsOpen, setSettingsOpen] = useState(false); const [routingDraft, setRoutingDraft] = useState(null); const [overviewExpanded, setOverviewExpanded] = useState(true); const deferredSignalSearch = useDeferredValue(signalSearch.trim()); const { data: boardData, isLoading, error } = useQuery({ queryKey: ["sstc-board-overview", id], queryFn: () => sstcApi.getBoardOverview(id!), enabled: !!id, refetchInterval: LIVE_REFRESH_MS, refetchOnWindowFocus: true, }); const { data: chain } = useQuery({ queryKey: ["sstc-chain", id], queryFn: () => sstcApi.getChain(id!), enabled: !!id, refetchInterval: LIVE_REFRESH_MS, refetchOnWindowFocus: true, }); const { data: signalsPayload } = useQuery({ queryKey: ["sstc-signals", id, signalPage, signalFilters], queryFn: () => sstcApi.listSignals(id!, { status: signalFilters.status || undefined, keyword: signalFilters.keyword.trim() || undefined, source: signalFilters.source || undefined, priority: signalFilters.priority || undefined, created_from: signalFilters.created_from || undefined, created_to: signalFilters.created_to || undefined, limit: SIGNAL_PAGE_SIZE, offset: (signalPage - 1) * SIGNAL_PAGE_SIZE, }), enabled: !!id, refetchInterval: LIVE_REFRESH_MS, refetchOnWindowFocus: true, }); const signals = signalsPayload?.items || []; const signalTotal = Number(signalsPayload?.total || 0); const signalTotalPages = Math.max(1, Math.ceil(signalTotal / SIGNAL_PAGE_SIZE)); const { data: signalPickerPayload } = useQuery({ queryKey: ["sstc-signals-picker", id], queryFn: () => sstcApi.listSignals(id!, { limit: 200, offset: 0, }), enabled: !!id && createCharterOpen && showAllSignalOptions, staleTime: 60_000, }); const { data: charters = [] } = useQuery({ queryKey: ["sstc-charters", id], queryFn: () => sstcApi.listCharters(id!), enabled: !!id, refetchInterval: LIVE_REFRESH_MS, refetchOnWindowFocus: true, }); const { data: tasks = [] } = useQuery({ queryKey: ["sstc-task-board", id], queryFn: () => sstcApi.getTaskBoard(id!), enabled: !!id, refetchInterval: LIVE_REFRESH_MS, refetchOnWindowFocus: true, }); const { data: releases = [] } = useQuery({ queryKey: ["sstc-releases", id], queryFn: () => sstcApi.listReleases(id!), enabled: !!id, refetchInterval: LIVE_REFRESH_MS, refetchOnWindowFocus: true, }); const { data: reviews = [] } = useQuery({ queryKey: ["sstc-reviews", id], queryFn: () => sstcApi.listReviews(id!), enabled: !!id, refetchInterval: LIVE_REFRESH_MS, refetchOnWindowFocus: true, }); const { data: routingConfig, isLoading: routingConfigLoading } = useQuery({ queryKey: ["sstc-ai-routing-config", id], queryFn: () => sstcApi.getAIRoutingConfig(id!), enabled: !!id, refetchInterval: LIVE_REFRESH_MS, refetchOnWindowFocus: true, }); const numericProjectId = Number(id || 0); const { data: pmoWorkItemsPayload } = useQuery({ queryKey: ["ai-pmo-work-items", id], queryFn: () => aiPmoApi.listWorkItems({ projectId: numericProjectId, limit: 6 }), enabled: !!id && Number.isFinite(numericProjectId) && numericProjectId > 0, refetchInterval: LIVE_REFRESH_MS, refetchOnWindowFocus: true, }); const { data: pmoRiskSnapshot } = useQuery({ queryKey: ["ai-pmo-cadence", "risk_review", id], queryFn: () => aiPmoApi.getLatestCadence({ cadenceType: "risk_review", projectId: numericProjectId }), enabled: !!id && Number.isFinite(numericProjectId) && numericProjectId > 0, refetchInterval: LIVE_REFRESH_MS, refetchOnWindowFocus: true, }); const { data: pmoDailySnapshot } = useQuery({ queryKey: ["ai-pmo-cadence", "daily_standup", id], queryFn: () => aiPmoApi.getLatestCadence({ cadenceType: "daily_standup", projectId: numericProjectId }), enabled: !!id && Number.isFinite(numericProjectId) && numericProjectId > 0, refetchInterval: LIVE_REFRESH_MS, refetchOnWindowFocus: true, }); useEffect(() => { if (routingConfig) { setRoutingDraft(routingConfig); } }, [routingConfig]); const updateRoutingConfigMutation = useMutation({ mutationFn: (payload: { cold_start_enabled: boolean; signal_auto_enter_threshold: number; project_auto_create_threshold: number; project_uncertain_threshold: number; }) => sstcApi.updateAIRoutingConfig(id!, payload), onSuccess: (data) => { setRoutingDraft(data); setSettingsOpen(false); queryClient.invalidateQueries({ queryKey: ["sstc-ai-routing-config", id] }); refreshAll(); }, }); useEffect(() => { if (!selectedCharterId && charters.length > 0) { setSelectedCharterId(charters[0].id); } }, [charters, selectedCharterId]); useEffect(() => { if (typeof window === "undefined") return; const syncViewport = () => setIsMobileViewport(window.innerWidth < 768); syncViewport(); window.addEventListener("resize", syncViewport); return () => window.removeEventListener("resize", syncViewport); }, []); useEffect(() => { setSignalPage(1); setSignalFilters(DEFAULT_SIGNAL_FILTERS); }, [id]); const refreshAll = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["sstc-board-overview", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-chain", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-signals", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-signals-picker", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-charters", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-task-board", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-releases", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-reviews", id] }), queryClient.invalidateQueries({ queryKey: ["sstc-governance", id] }), queryClient.invalidateQueries({ queryKey: ["workloads"] }), ]); }; const resetCreateCharterDraft = () => { setNewCharterTitle(""); setNewCharterRawText(""); setSelectedSignalIds([]); setCharterSeedSignalId(null); setSignalSearch(""); setShowAllSignalOptions(false); }; const handleCreateCharterDialogChange = (open: boolean) => { setCreateCharterOpen(open); if (!open && !createCharterMutation.isPending) { resetCreateCharterDraft(); } }; async function runCharterPrefillInBackground(charterId: string, rawText: string) { try { await sstcApi.aiPrefillCharter( id!, charterId, rawText ? { raw_text: rawText } : {} ); await refreshAll(); toast({ title: "宪章草稿已自动补全 6 模块", variant: "success" }); } catch (error) { toast({ title: getApiErrorMessage(error, "AI 补全失败,请在宪章页手动重试"), variant: "error", }); } } const createCharterMutation = useMutation({ mutationFn: async () => { const rawText = newCharterRawText.trim(); const charter = await sstcApi.createCharter(id!, { title: compactTitle(newCharterTitle, 24), content: rawText ? { raw_source_text: rawText } : undefined, signal_ids: selectedSignalIds.map((v) => Number(v)), skip_ai_prefill: true, }); return { charter, rawText }; }, onSuccess: ({ charter, rawText }) => { setActiveStage("charter"); setSelectedCharterId(charter.id); setCreateCharterOpen(false); resetCreateCharterDraft(); void refreshAll(); toast({ title: "宪章已创建,AI 正在后台补全 6 模块", variant: "success", }); void runCharterPrefillInBackground(charter.id, rawText); }, onError: (error) => { toast({ title: getApiErrorMessage(error, "创建宪章失败"), variant: "error", }); }, }); const stageNodes = boardData?.stages || []; const overview = boardData?.overview; const risks = boardData?.risks; const selectedStageLocked = useMemo( () => stageNodes.find((s) => s.key === activeStage)?.locked ?? false, [stageNodes, activeStage] ); const compactNewCharterTitle = compactTitle(newCharterTitle, 24); const boardTitle = compactTitle( formatProjectNameWithId({ id: overview?.project?.id || chain?.project?.id || id, title: overview?.project?.title || chain?.project?.title || "项目看板", }), 24 ); const pmoWorkItems = useMemo( () => dedupeWorkItemsBySemanticKey(pmoWorkItemsPayload?.items || []), [pmoWorkItemsPayload?.items] ); const pendingManualWorkItemCount = useMemo( () => pmoWorkItems.filter((item) => item.state === "waiting_manual").length, [pmoWorkItems] ); const riskSummary = (pmoRiskSnapshot?.summary || {}) as Record; const dailyPayload = (pmoDailySnapshot?.payload || {}) as Record; const riskActions = Array.isArray(dailyPayload.focus_actions) ? (dailyPayload.focus_actions as string[]).slice(0, 2) : []; const currentSignalIds = useMemo(() => new Set(signals.map((signal) => signal.id)), [signals]); const signalOptionPool = useMemo(() => { const merged = new Map(); signals.forEach((signal) => merged.set(signal.id, signal)); (signalPickerPayload?.items || []).forEach((signal) => merged.set(signal.id, signal)); return Array.from(merged.values()); }, [signals, signalPickerPayload?.items]); const signalOptions = useMemo(() => { const keyword = deferredSignalSearch.toLowerCase(); const scoped = signalOptionPool.filter((signal) => { if (showAllSignalOptions) return true; return ( currentSignalIds.has(signal.id) || signal.id === charterSeedSignalId || selectedSignalIds.includes(signal.id) ); }); const filtered = keyword ? scoped.filter((signal) => { const haystack = [ signal.title, signal.content, signal.proposer_name, ] .filter(Boolean) .join(" ") .toLowerCase(); return haystack.includes(keyword); }) : scoped; return filtered.sort((a, b) => { const aPinned = Number(a.id === charterSeedSignalId || selectedSignalIds.includes(a.id)); const bPinned = Number(b.id === charterSeedSignalId || selectedSignalIds.includes(b.id)); return bPinned - aPinned; }); }, [ charterSeedSignalId, currentSignalIds, deferredSignalSearch, selectedSignalIds, showAllSignalOptions, signalOptionPool, ]); useEffect(() => { if (!createCharterOpen) return; setSelectedSignalIds((prev) => prev.filter((sid) => { const signal = signalOptionPool.find((item) => item.id === sid); if (!signal) return false; return signal.can_create_charter !== false; }) ); }, [createCharterOpen, signalOptionPool]); const toggleSignal = (sid: string) => { const signal = signalOptionPool.find((item) => item.id === sid); if (signal && signal.can_create_charter === false) return; setSelectedSignalIds((prev) => prev.includes(sid) ? prev.filter((x) => x !== sid) : [...prev, sid] ); }; const handleSignalFiltersChange = (patch: Partial) => { setSignalPage(1); setSignalFilters((prev) => ({ ...prev, ...patch })); }; const resetSignalFilters = () => { setSignalPage(1); setSignalFilters(DEFAULT_SIGNAL_FILTERS); }; const openCreateCharterDialog = () => { resetCreateCharterDraft(); setCreateCharterOpen(true); }; const openCreateCharterFromSignal = (signal: SSTCSignal) => { if (signal.can_create_charter === false) { toast({ title: signal.charter_lock_reason || "该信号已关联宪章,禁止重复新建", variant: "error", }); return; } const titleSource = signal.title || signal.content || "新需求"; setNewCharterTitle(compactTitle(titleSource, 24)); setNewCharterRawText(""); setSelectedSignalIds([signal.id]); setCharterSeedSignalId(signal.id); setSignalSearch(""); setShowAllSignalOptions(false); setCreateCharterOpen(true); }; return (

{boardTitle}

SSTC 唯一事实链路 · 可视化操作中枢

SSTC 看板 模型能力矩阵 预算管控 {isLoading ? (

加载中…

) : error || !overview ? ( 加载失败:{String(error)} ) : ( <>
navigate("/ai-steward")} onOpenSettings={() => setSettingsOpen(true)} onOpenWorkloads={() => navigate("/workloads")} onExport={() => { const payload = JSON.stringify({ overview, chain, stageNodes, risks }, null, 2); const blob = new Blob([payload], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `链路导出-${id}.json`; a.click(); URL.revokeObjectURL(url); }} />
AI PMO 风险节奏

最新 cadence 快照已接入项目看板,风险、阻塞和待人工确认不再只存在旧队列里。

{[ { label: "Blocked Tasks", value: Number(riskSummary.blocked_task_count || 0) }, { label: "待确认信号源", value: Number(riskSummary.pending_signal_source_count || 0) }, { label: "风险通知", value: Number(riskSummary.unread_risk_notification_count || 0) }, { label: "待确认工作项", value: pendingManualWorkItemCount }, ].map((metric) => (
{metric.label}
{metric.value}
))}
当前焦点动作
{riskActions.length > 0 ? ( riskActions.map((action) => (
{action}
)) ) : (
当前暂无新增高风险节奏动作。
)}
AI PMO 工作项
{pmoWorkItems.length === 0 ? (
当前项目暂无 PMO 工作项。
建议下一步:先在任务阶段创建首批任务,或前往消息台确认待处理信号。
) : ( pmoWorkItems.map((item) => ( )) )}
setSignalPage(Math.min(Math.max(1, nextPage), signalTotalPages)) } onSelectCharter={setSelectedCharterId} onCreateCharterFromSignal={openCreateCharterFromSignal} onSignalFiltersChange={handleSignalFiltersChange} onResetSignalFilters={resetSignalFilters} onRefresh={refreshAll} /> {selectedStageLocked && (

当前阶段处于锁定态,仅可查看;请先完成上游阶段。

)} )}
setCreateCharterOpen(false)} onCreate={() => createCharterMutation.mutate()} isCreating={createCharterMutation.isPending} canCreate={Boolean(compactNewCharterTitle) && selectedSignalIds.length > 0} /> { setSettingsOpen(false); setRoutingDraft(routingConfig || null); }} onSave={(payload) => { updateRoutingConfigMutation.mutate(payload); }} />
); }