684 lines
28 KiB
TypeScript
684 lines
28 KiB
TypeScript
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<StageKey>("signal");
|
||
const [selectedCharterId, setSelectedCharterId] = useState<string | null>(null);
|
||
const [createCharterOpen, setCreateCharterOpen] = useState(false);
|
||
const [newCharterTitle, setNewCharterTitle] = useState("");
|
||
const [newCharterRawText, setNewCharterRawText] = useState("");
|
||
const [selectedSignalIds, setSelectedSignalIds] = useState<string[]>([]);
|
||
const [charterSeedSignalId, setCharterSeedSignalId] = useState<string | null>(null);
|
||
const [signalSearch, setSignalSearch] = useState("");
|
||
const [showAllSignalOptions, setShowAllSignalOptions] = useState(false);
|
||
const [signalPage, setSignalPage] = useState(1);
|
||
const [signalFilters, setSignalFilters] = useState<SignalFilterState>(DEFAULT_SIGNAL_FILTERS);
|
||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||
const [routingDraft, setRoutingDraft] = useState<AIRoutingConfig | null>(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<string, number>;
|
||
const dailyPayload = (pmoDailySnapshot?.payload || {}) as Record<string, unknown>;
|
||
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<string, SSTCSignal>();
|
||
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<SignalFilterState>) => {
|
||
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 (
|
||
<div className="space-y-4 pb-24">
|
||
<div className="flex items-center gap-4">
|
||
<Button variant="ghost" size="icon" asChild>
|
||
<Link to="/projects">
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</Link>
|
||
</Button>
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">
|
||
{boardTitle}
|
||
</h1>
|
||
<p className="text-muted-foreground">SSTC 唯一事实链路 · 可视化操作中枢</p>
|
||
</div>
|
||
<Button className="ml-auto" onClick={openCreateCharterDialog}>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
新建宪章
|
||
</Button>
|
||
</div>
|
||
|
||
<Tabs defaultValue="sstc" className="w-full">
|
||
<TabsList className="grid w-full grid-cols-3">
|
||
<TabsTrigger value="sstc">SSTC 看板</TabsTrigger>
|
||
<TabsTrigger value="capability">模型能力矩阵</TabsTrigger>
|
||
<TabsTrigger value="budget">预算管控</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="sstc" className="space-y-4">
|
||
{isLoading ? (
|
||
<p className="text-muted-foreground">加载中…</p>
|
||
) : error || !overview ? (
|
||
<Card>
|
||
<CardContent className="py-12 text-center text-destructive">
|
||
加载失败:{String(error)}
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<>
|
||
<div className="overflow-hidden rounded-2xl border border-slate-200/80 bg-white/70 shadow-sm">
|
||
<button
|
||
type="button"
|
||
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left transition hover:bg-slate-50/80"
|
||
onClick={() => setOverviewExpanded((prev) => !prev)}
|
||
aria-expanded={overviewExpanded}
|
||
>
|
||
<div>
|
||
<div className="text-sm font-semibold text-slate-900">项目概览</div>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
汇总项目状态、团队负载、PMO 风险节奏与待处理工作项。
|
||
</p>
|
||
</div>
|
||
<span className="inline-flex items-center gap-1 text-sm font-medium text-slate-600">
|
||
{overviewExpanded ? "收起" : "展开"}
|
||
{overviewExpanded ? (
|
||
<ChevronDown className="h-4 w-4" />
|
||
) : (
|
||
<ChevronRight className="h-4 w-4" />
|
||
)}
|
||
</span>
|
||
</button>
|
||
|
||
<div
|
||
className={cn(
|
||
"grid transition-all duration-300 ease-out",
|
||
overviewExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
|
||
)}
|
||
>
|
||
<div className="overflow-hidden">
|
||
<div className="space-y-4 border-t border-slate-200/80 px-5 py-5">
|
||
<SSTCGlobalHeader
|
||
overview={overview}
|
||
onOpenAi={() => 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);
|
||
}}
|
||
/>
|
||
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
<Card className="xl:col-span-2">
|
||
<CardContent className="space-y-4 p-5">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-slate-900">AI PMO 风险节奏</div>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
最新 cadence 快照已接入项目看板,风险、阻塞和待人工确认不再只存在旧队列里。
|
||
</p>
|
||
</div>
|
||
<Button variant="outline" size="sm" onClick={() => navigate(`/inbox?project_id=${id}§ion=action_required`)}>
|
||
查看收件箱
|
||
</Button>
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-4">
|
||
{[
|
||
{ 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) => (
|
||
<div key={metric.label} className="rounded-xl bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.12em] text-slate-500">{metric.label}</div>
|
||
<div className="mt-2 text-2xl font-bold text-slate-900">{metric.value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||
<div className="text-xs font-semibold tracking-[0.18em] text-slate-500">当前焦点动作</div>
|
||
<div className="mt-2 space-y-2">
|
||
{riskActions.length > 0 ? (
|
||
riskActions.map((action) => (
|
||
<div key={action} className="text-sm text-slate-700">
|
||
{action}
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="text-sm text-slate-500">当前暂无新增高风险节奏动作。</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardContent className="space-y-3 p-5">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-sm font-semibold text-slate-900">AI PMO 工作项</div>
|
||
<Button variant="ghost" size="sm" onClick={() => navigate(`/inbox?project_id=${id}`)}>
|
||
全部查看
|
||
</Button>
|
||
</div>
|
||
{pmoWorkItems.length === 0 ? (
|
||
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6">
|
||
<div className="text-sm text-slate-600">当前项目暂无 PMO 工作项。</div>
|
||
<div className="mt-2 text-xs text-slate-500">
|
||
建议下一步:先在任务阶段创建首批任务,或前往消息台确认待处理信号。
|
||
</div>
|
||
<div className="mt-3 flex gap-2">
|
||
<Button size="sm" variant="outline" onClick={() => setActiveStage("task")}>
|
||
去任务阶段
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => navigate(`/inbox?project_id=${id}`)}>
|
||
打开消息台
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
pmoWorkItems.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-left transition hover:bg-white"
|
||
onClick={() => navigate(`/inbox?project_id=${id}&q=${encodeURIComponent(item.title)}`)}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="line-clamp-2 text-sm font-medium text-slate-900">{toReadableWorkItemTitle(item)}</div>
|
||
{item.state === "waiting_manual" && <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />}
|
||
</div>
|
||
<div className="mt-2 text-xs text-slate-500">{item.stage_text || item.state}</div>
|
||
<div className="mt-2 line-clamp-2 text-xs text-slate-600">{toReadableWorkItemSummary(item)}</div>
|
||
</button>
|
||
))
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<SSTCStageNav
|
||
stages={stageNodes}
|
||
activeStage={activeStage}
|
||
signalPreview={chain?.signals?.slice(0, 6) || signals.slice(0, 6)}
|
||
onChange={setActiveStage}
|
||
/>
|
||
|
||
<SSTCWorkspace
|
||
projectId={id!}
|
||
activeStage={activeStage}
|
||
isMobileViewport={isMobileViewport}
|
||
charterReadOnly={isMobileViewport}
|
||
stages={stageNodes}
|
||
signals={signals}
|
||
signalFilters={signalFilters}
|
||
charters={charters}
|
||
tasks={tasks}
|
||
releases={releases}
|
||
reviews={reviews}
|
||
selectedCharterId={selectedCharterId}
|
||
signalPage={signalPage}
|
||
signalTotal={signalTotal}
|
||
signalPageSize={SIGNAL_PAGE_SIZE}
|
||
onSignalPageChange={(nextPage) =>
|
||
setSignalPage(Math.min(Math.max(1, nextPage), signalTotalPages))
|
||
}
|
||
onSelectCharter={setSelectedCharterId}
|
||
onCreateCharterFromSignal={openCreateCharterFromSignal}
|
||
onSignalFiltersChange={handleSignalFiltersChange}
|
||
onResetSignalFilters={resetSignalFilters}
|
||
onRefresh={refreshAll}
|
||
/>
|
||
|
||
{selectedStageLocked && (
|
||
<p className="text-xs text-amber-700">
|
||
当前阶段处于锁定态,仅可查看;请先完成上游阶段。
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="capability">
|
||
<ModelCapabilityMatrix projectId={id!} />
|
||
</TabsContent>
|
||
|
||
<TabsContent value="budget">
|
||
<BudgetControlPanel projectId={id!} />
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<CreateCharterDialog
|
||
open={createCharterOpen}
|
||
onOpenChange={handleCreateCharterDialogChange}
|
||
newCharterTitle={newCharterTitle}
|
||
setNewCharterTitle={setNewCharterTitle}
|
||
compactNewCharterTitle={compactNewCharterTitle}
|
||
newCharterRawText={newCharterRawText}
|
||
setNewCharterRawText={setNewCharterRawText}
|
||
selectedSignalIds={selectedSignalIds}
|
||
charterSeedSignalId={charterSeedSignalId}
|
||
showAllSignalOptions={showAllSignalOptions}
|
||
setShowAllSignalOptions={setShowAllSignalOptions}
|
||
signalSearch={signalSearch}
|
||
setSignalSearch={setSignalSearch}
|
||
signalPickerLoaded={Boolean(signalPickerPayload)}
|
||
signalOptions={signalOptions}
|
||
onToggleSignal={toggleSignal}
|
||
onCancel={() => setCreateCharterOpen(false)}
|
||
onCreate={() => createCharterMutation.mutate()}
|
||
isCreating={createCharterMutation.isPending}
|
||
canCreate={Boolean(compactNewCharterTitle) && selectedSignalIds.length > 0}
|
||
/>
|
||
|
||
<ProjectSettingsDialog
|
||
open={settingsOpen}
|
||
onOpenChange={setSettingsOpen}
|
||
routingDraft={routingDraft}
|
||
setRoutingDraft={setRoutingDraft}
|
||
routingConfigLoading={routingConfigLoading}
|
||
isSaving={updateRoutingConfigMutation.isPending}
|
||
onCancel={() => {
|
||
setSettingsOpen(false);
|
||
setRoutingDraft(routingConfig || null);
|
||
}}
|
||
onSave={(payload) => {
|
||
updateRoutingConfigMutation.mutate(payload);
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|