358 lines
16 KiB
TypeScript
358 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|