PMFlow/frontend/src/pages/ai-steward/sections/OverviewTab.tsx

358 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from "react";
import { Download, RefreshCcw, Sparkles } from "lucide-react";
import { TabsContent } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import type {
AIStewardTab,
BriefingTarget,
DailyBriefingV2Payload,
EngineMessage,
ExecutiveBriefingConfig,
ExecutiveBriefingPayload,
ExecutiveBriefingSection,
} from "@/pages/ai-steward/types";
import BriefingDetailDrawer from "../components/BriefingDetailDrawer";
import BriefingSectionCard from "../components/BriefingSectionCard";
import BriefingTab from "./BriefingTab";
interface OverviewTabProps {
canManageExecutiveBriefing: boolean;
dailyBriefing: DailyBriefingV2Payload | null;
dailyBriefingError: string | null;
dailyBriefingLoading: boolean;
username?: string;
executiveBriefing: ExecutiveBriefingPayload | null;
executiveBriefingError: string | null;
executiveBriefingMode: "snapshot" | "live";
executiveBriefingLoading: boolean;
executiveBriefingConfigDraft: ExecutiveBriefingConfig;
executiveBriefingConfigLoading: boolean;
executiveBriefingConfigError: string | null;
executiveBriefingSaveLoading: boolean;
executiveBriefingSaveMessage: EngineMessage | null;
onLoadSnapshot: () => void;
onRefreshLive: () => void;
onExportReport: () => void;
onConfigChange: (patch: Partial<ExecutiveBriefingConfig>) => void;
onSaveConfig: () => void;
onOpenTarget: (target?: BriefingTarget | null, fallbackTab?: AIStewardTab) => void;
}
const SECTION_STYLE: Record<string, string> = {
decisions: "border-amber-200 bg-amber-50/70 dark:border-amber-900 dark:bg-amber-950/30",
input_health: "border-sky-200 bg-sky-50/70 dark:border-sky-900 dark:bg-sky-950/30",
delivery_risk: "border-rose-200 bg-rose-50/70 dark:border-rose-900 dark:bg-rose-950/30",
team_capacity: "border-emerald-200 bg-emerald-50/70 dark:border-emerald-900 dark:bg-emerald-950/30",
ai_governance: "border-indigo-200 bg-indigo-50/70 dark:border-indigo-900 dark:bg-indigo-950/30",
external_metrics: "border-slate-200 bg-slate-50/70 dark:border-slate-800 dark:bg-slate-950/30",
};
function formatTimestamp(value?: string | null): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function formatMetricValue(value: string | number): string {
return typeof value === "number" ? String(value) : value;
}
function toneBadgeClass(tone: string): string {
switch (tone) {
case "critical":
return "bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-200";
case "warning":
return "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200";
case "positive":
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200";
case "info":
return "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-200";
default:
return "bg-slate-100 text-slate-700 dark:bg-slate-900 dark:text-slate-200";
}
}
export default function OverviewTab({
canManageExecutiveBriefing,
dailyBriefing,
dailyBriefingError,
dailyBriefingLoading,
username,
executiveBriefing,
executiveBriefingError,
executiveBriefingMode,
executiveBriefingLoading,
executiveBriefingConfigDraft,
executiveBriefingConfigLoading,
executiveBriefingConfigError,
executiveBriefingSaveLoading,
executiveBriefingSaveMessage,
onLoadSnapshot,
onRefreshLive,
onExportReport,
onConfigChange,
onSaveConfig,
onOpenTarget,
}: OverviewTabProps) {
const [drawerSection, setDrawerSection] = useState<ExecutiveBriefingSection | null>(null);
const [drawerProjectKey, setDrawerProjectKey] = useState<string | null>(null);
if (!canManageExecutiveBriefing) {
return (
<BriefingTab
tabValue="overview"
briefing={dailyBriefing}
briefingError={dailyBriefingError}
briefingLoading={dailyBriefingLoading}
briefingMode={executiveBriefingMode}
username={username}
onLoadSnapshot={onLoadSnapshot}
onRefreshLive={onRefreshLive}
onExportReport={onExportReport}
onOpenTarget={onOpenTarget}
/>
);
}
const sections = executiveBriefing?.sections ?? [];
const hero = executiveBriefing?.hero;
const openDrawer = (section: ExecutiveBriefingSection, projectKey?: string) => {
setDrawerSection(section);
setDrawerProjectKey(projectKey || null);
};
return (
<TabsContent value="overview" className="mt-6">
<div className="space-y-6">
<Card className="overflow-hidden border-amber-200 bg-gradient-to-br from-stone-50 via-amber-50 to-orange-100 shadow-sm dark:border-amber-900 dark:from-slate-950 dark:via-stone-950 dark:to-amber-950/50">
<CardContent className="p-6">
<div className="flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Badge className="bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900">
{executiveBriefingMode === "live" ? "实时版" : "晨报快照"}
</Badge>
{hero?.snapshot_state === "missing" && (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-200">
今日快照未生成
</Badge>
)}
</div>
<div>
<h2 className="flex items-center gap-3 text-3xl font-semibold tracking-tight text-slate-950 dark:text-slate-50">
<Sparkles className="h-7 w-7 text-amber-600 dark:text-amber-300" />
简报中心
</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300">
先看异常,再按项目展开预览,右侧抽屉里看完整详情并决定下一步去项目、成员还是工作台。
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 dark:border-slate-800 dark:bg-slate-900/70">
<p className="text-sm text-slate-500">需拍板事项</p>
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">{hero?.decision_count ?? 0}</p>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 dark:border-slate-800 dark:bg-slate-900/70">
<p className="text-sm text-slate-500">异常总数</p>
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">{hero?.anomaly_count ?? 0}</p>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 dark:border-slate-800 dark:bg-slate-900/70">
<p className="text-sm text-slate-500">查看日期</p>
<p className="mt-2 text-3xl font-semibold text-slate-950 dark:text-slate-50">
{executiveBriefing?.brief_date || "-"}
</p>
</div>
</div>
</div>
<div className="min-w-0 space-y-3 xl:w-[380px]">
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-4 text-sm dark:border-slate-800 dark:bg-slate-900/70">
<div className="flex items-center justify-between gap-3">
<span className="text-slate-500">晨报快照</span>
<span className="font-medium text-slate-900 dark:text-slate-100">{formatTimestamp(hero?.baseline_generated_at)}</span>
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-slate-500">实时刷新</span>
<span className="font-medium text-slate-900 dark:text-slate-100">{formatTimestamp(hero?.live_generated_at)}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{(hero?.key_alerts ?? []).map((item) => (
<Badge key={`${item.label}-${item.value}`} className={toneBadgeClass(item.tone)}>
{item.label} {formatMetricValue(item.value)}
</Badge>
))}
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
onClick={onLoadSnapshot}
disabled={executiveBriefingLoading}
className="border-slate-300 bg-white/80 dark:border-slate-700 dark:bg-slate-900/60"
>
查看晨报快照
</Button>
<Button
onClick={onRefreshLive}
disabled={executiveBriefingLoading}
className="bg-slate-900 text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
>
<RefreshCcw className="mr-2 h-4 w-4" />
实时刷新
</Button>
<Button
variant="outline"
onClick={onExportReport}
disabled={!executiveBriefing}
className="border-slate-300 bg-white/80 dark:border-slate-700 dark:bg-slate-900/60"
>
<Download className="mr-2 h-4 w-4" />
导出报告
</Button>
</div>
</div>
</div>
{executiveBriefingLoading && (
<p className="mt-4 text-sm text-slate-500 dark:text-slate-400">正在更新晨报数据...</p>
)}
{executiveBriefingError && (
<p className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/40 dark:text-rose-200">
{executiveBriefingError}
</p>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-5 xl:grid-cols-2">
{sections.map((section) => (
<BriefingSectionCard
key={section.key}
section={section}
className={SECTION_STYLE[section.key] || SECTION_STYLE.external_metrics}
emptyMessage="当前区块暂无新的异常或风险提示。"
onOpenDrawer={openDrawer}
/>
))}
</div>
<Card className="border-slate-200 shadow-sm dark:border-slate-800">
<CardContent className="p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-xl">
<h3 className="text-xl font-semibold text-slate-950 dark:text-slate-50">晨报调度配置</h3>
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-300">
这份配置是租户级的。修改后,Web 快照、定时调度和管理群自动推送都会沿用同一套晨报口径。
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">自动发送</span>
<Switch
checked={executiveBriefingConfigDraft.enabled}
onCheckedChange={(checked) => onConfigChange({ enabled: checked })}
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
/>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">发送时间</p>
<Input
type="time"
value={executiveBriefingConfigDraft.send_time}
onChange={(event) => onConfigChange({ send_time: event.target.value })}
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">发送周期</p>
<div className="flex gap-2">
<Button
variant={executiveBriefingConfigDraft.send_cycle === "workdays" ? "default" : "outline"}
size="sm"
onClick={() => onConfigChange({ send_cycle: "workdays" })}
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
>
工作日
</Button>
<Button
variant={executiveBriefingConfigDraft.send_cycle === "daily" ? "default" : "outline"}
size="sm"
onClick={() => onConfigChange({ send_cycle: "daily" })}
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
>
每天
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">管理群 Chat ID</p>
<Input
value={executiveBriefingConfigDraft.delivery_target_ref || ""}
onChange={(event) => onConfigChange({ delivery_target_ref: event.target.value || null })}
placeholder="oc_management_group"
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">时区</p>
<Input
value={executiveBriefingConfigDraft.timezone}
onChange={(event) => onConfigChange({ timezone: event.target.value })}
placeholder="Asia/Shanghai"
disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}
/>
</div>
</div>
<div className="mt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
{executiveBriefingConfigError && (
<p className="text-sm text-rose-600 dark:text-rose-300">{executiveBriefingConfigError}</p>
)}
{executiveBriefingSaveMessage && (
<p
className={`text-sm ${
executiveBriefingSaveMessage.type === "success"
? "text-emerald-600 dark:text-emerald-300"
: "text-rose-600 dark:text-rose-300"
}`}
>
{executiveBriefingSaveMessage.text}
</p>
)}
</div>
<Button onClick={onSaveConfig} disabled={executiveBriefingConfigLoading || executiveBriefingSaveLoading}>
保存晨报配置
</Button>
</div>
</CardContent>
</Card>
<BriefingDetailDrawer
open={Boolean(drawerSection)}
section={drawerSection}
focusedProjectKey={drawerProjectKey}
onOpenChange={(open) => {
if (!open) {
setDrawerSection(null);
setDrawerProjectKey(null);
}
}}
onOpenTarget={onOpenTarget}
/>
</div>
</TabsContent>
);
}