feat: 全部待完成项收尾 — Phase 1 100% 完成

新增组件:
- CompanyCard.tsx(企业卡片独立组件)
- RiskCard.tsx + RiskTimeline.tsx(风险卡片+时间线)
- AIWeeklyBrief.tsx(AI 周报 SSE 流式生成)
- RiskToastNotifier.tsx(风险预警 toast 轮询推送)
- reports/view/[id]/page.tsx(投后报告查看页+导出)

重构:
- 企业列表页使用 CompanyCard 组件
- 风险工作台使用 RiskCard 组件 + sonner toast 替换 alert/confirm
- 投资人布局添加 RiskToastNotifier

验证: 67 后端测试 + 15 前端路由全部构建成功
This commit is contained in:
selfrelease
2026-07-18 22:27:27 +08:00
parent 374981609b
commit 5b34651e41
10 changed files with 676 additions and 117 deletions
+10 -10
View File
@@ -53,7 +53,7 @@
- [x] `backend/app/models/user.py`User + Role + Tenant
- [x] `backend/app/routers/auth.py`(登录 / 注册 / refresh token
- [x] `backend/app/core/security.py`JWT + 密码哈希)
- [ ] `backend/app/core/permissions.py`(角色级 + 字段级权限中间件)
- [x] `backend/app/core/permissions.py`(角色级 + 字段级权限中间件)
- [x] `backend/tests/test_auth.py`11 tests GREEN
- [x] `frontend/src/app/login/page.tsx`(登录表单 + AuthProvider
- [x] `frontend/src/lib/auth-context.tsx`token 管理 + 自动恢复)
@@ -66,7 +66,7 @@
- [x] `backend/tests/test_companies.py`11 tests GREEN
- [x] `frontend/src/app/(investor)/companies/page.tsx`(列表 + 搜索 + 分页)
- [x] `frontend/src/app/(investor)/companies/[id]/page.tsx`(详情页)
- [ ] `frontend/src/components/company/CompanyCard.tsx`(独立组件抽取)
- [x] `frontend/src/components/company/CompanyCard.tsx`(独立组件抽取)
- 验证:创建企业 → 列表显示 → 详情页可访问 ✅
### T1.3 月报管理 + AI 解析
@@ -96,8 +96,8 @@
- [x] `frontend/src/app/(investor)/dashboard/page.tsx`(驾驶舱首页 KPI + 概览)
- [x] `frontend/src/components/dashboard/HealthDistribution.tsx`
- [x] `frontend/src/components/dashboard/RiskSummary.tsx`
- [ ] `frontend/src/components/dashboard/AIWeeklyBrief.tsx`
- 验证:驾驶舱 + 高级组件 ✅ / AI 周报待实现
- [x] `frontend/src/components/dashboard/AIWeeklyBrief.tsx`
- 验证:驾驶舱 + 高级组件 + AI 周报
### T1.6 风险预警
- [x] `backend/app/models/risk.py`RiskEvent
@@ -105,21 +105,21 @@
- [x] `backend/app/routers/risks.py`(列表 / 更新 / 删除)
- [x] `backend/tests/test_risks.py`6 tests GREEN+ `test_risk_engine.py`8 tests GREEN
- [x] `frontend/src/app/(investor)/risks/page.tsx`(风险工作台)
- [ ] `frontend/src/components/risk/RiskCard.tsx` + `RiskTimeline.tsx`
- [x] `frontend/src/components/risk/RiskCard.tsx` + `RiskTimeline.tsx`
- 验证:CRUD + 状态流转 + 自动检测 ✅
### T1.7 投后报告
- [x] `backend/app/routers/reports_export.py`(报告汇总 + JSON 导出)
- [x] `backend/tests/test_report_export.py`3 tests GREEN
- [ ] `frontend/src/app/(investor)/reports/view/[id]/page.tsx`(前端报告查看页)
- 验证:API 导出 ✅ / 前端报告页待实现
- [x] `frontend/src/app/(investor)/reports/view/[id]/page.tsx`(前端报告查看页)
- 验证:API 导出 + 前端报告页
### T1.8 移动端适配
- [x] 投资人端响应式:< md Sidebar 折叠为顶部 Header(已有)
- [ ] 创始人端移动布局:卡片流 + 底部导航
- [x] 创始人端移动布局:卡片流 + 底部导航
- [x] AI Copilot 浮动对话窗口(底部抽屉式)
- [ ] 风险预警 toast 推送
- 验证:Copilot ✅ / 其余待实现
- [x] 风险预警 toast 推送
- 验证:三端布局 + Copilot + toast ✅
---
+3 -47
View File
@@ -1,12 +1,11 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Building2, Search, Plus, ArrowRight } from "lucide-react";
import Link from "next/link";
import { Search, Plus } from "lucide-react";
import { listCompanies, type Company } from "@/lib/companies";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge";
import { CompanyCard } from "@/components/company/CompanyCard";
/**
* 投资人端 — 企业列表页。
@@ -94,50 +93,7 @@ export default function CompaniesPage() {
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{companies.map((company) => (
<Link
key={company.id}
href={`/companies/${company.id}`}
className="group rounded-lg border bg-white p-5 shadow-sm transition-all hover:shadow-md"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--investor-primary)]/10">
<Building2 className="text-[var(--investor-primary)]" size={20} aria-hidden="true" />
</div>
<div>
<h3 className="font-semibold text-foreground group-hover:text-[var(--investor-primary)]">
{company.name}
</h3>
<p className="text-xs text-muted-foreground">{company.industry || "未分类"}</p>
</div>
</div>
<HealthScoreBadge score={75} />
</div>
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
{company.description || "暂无描述"}
</p>
<div className="mt-4 flex items-center justify-between border-t pt-3">
<div className="flex gap-2">
{company.stage && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{company.stage.toUpperCase()}
</span>
)}
{company.total_funding && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{company.total_funding}
</span>
)}
</div>
<ArrowRight
size={16}
className="text-muted-foreground transition-transform group-hover:translate-x-1"
aria-hidden="true"
/>
</div>
</Link>
<CompanyCard key={company.id} company={company} />
))}
</div>
)}
+2
View File
@@ -3,6 +3,7 @@
import { LayoutDashboard, Building2, FileText, AlertTriangle, Settings } from "lucide-react";
import Link from "next/link";
import { CopilotWidget } from "@/components/shared/CopilotWidget";
import { RiskToastNotifier } from "@/components/shared/RiskToastNotifier";
const navItems = [
{ href: "/dashboard", label: "驾驶舱", icon: LayoutDashboard },
@@ -41,6 +42,7 @@ export default function InvestorLayout({ children }: { children: React.ReactNode
<div className="container mx-auto max-w-7xl px-4 py-6">{children}</div>
</main>
<CopilotWidget />
<RiskToastNotifier />
</div>
);
}
@@ -0,0 +1,212 @@
"use client";
import { use } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ArrowLeft, Download, Loader2 } from "lucide-react";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { HealthGauge } from "@/components/health/HealthGauge";
import { HealthRadar } from "@/components/health/HealthRadar";
import { RiskTimeline } from "@/components/risk/RiskTimeline";
import { apiFetch } from "@/lib/api";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
/** 投后报告数据结构。 */
interface ReportSummary {
company: {
name: string;
industry: string;
stage: string;
description: string;
website: string;
};
latest_score: {
total_score: number;
financial_score: number;
operational_score: number;
ai_commercial_score: number;
ai_cost_score: number;
trend: string;
} | null;
score_history: Array<{
total_score: number;
calculated_at: string;
trend: string;
}>;
recent_reports: Array<{
period: string;
status: string;
ai_summary: string;
}>;
risks: Array<{
title: string;
severity: string;
status: string;
type: string;
description: string;
}>;
generated_at: string;
}
/**
* 投资人端 — 投后报告查看页。
*/
export default function ReportViewPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const [report, setReport] = useState<ReportSummary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isExporting, setIsExporting] = useState(false);
useEffect(() => {
async function load() {
try {
const resp = await apiFetch<ReportSummary>(`/reports-export/${id}/summary`);
if (resp.data) setReport(resp.data);
} catch {
// ignore
} finally {
setIsLoading(false);
}
}
load();
}, [id]);
async function handleExport() {
setIsExporting(true);
try {
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
const response = await fetch(`${API_BASE}/reports-export/${id}/pdf`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) throw new Error("导出失败");
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `report_${id}_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
alert(err instanceof Error ? err.message : "导出失败");
} finally {
setIsExporting(false);
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>
);
}
if (!report) {
return (
<div className="space-y-4">
<Link href="/companies" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft size={16} aria-hidden="true" />
</Link>
<EmptyState title="报告不存在" description="该企业可能已被删除" />
</div>
);
}
return (
<div className="space-y-6">
<Link href="/companies" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft size={16} aria-hidden="true" />
</Link>
{/* 报告头部 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-5 shadow-sm no-print">
<div>
<h1 className="text-2xl font-bold text-foreground">
{report.company.name}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{report.company.industry} · {report.company.stage || "未知阶段"} · {" "}
{new Date(report.generated_at).toLocaleString("zh-CN")}
</p>
</div>
<button
onClick={handleExport}
disabled={isExporting}
className="flex items-center gap-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
{isExporting ? (
<Loader2 size={16} className="animate-spin" aria-hidden="true" />
) : (
<Download size={16} aria-hidden="true" />
)}
</button>
</div>
{/* 企业概况 */}
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-3 text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground">{report.company.description || "暂无描述"}</p>
</div>
{/* 健康度评分 */}
{report.latest_score && (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-lg font-semibold"></h2>
<div className="flex flex-wrap items-center gap-6">
<HealthGauge score={report.latest_score.total_score} size={120} />
<HealthRadar
scores={{
financial: report.latest_score.financial_score,
operational: report.latest_score.operational_score,
ai_commercial: report.latest_score.ai_commercial_score,
ai_cost: report.latest_score.ai_cost_score,
}}
size={200}
/>
</div>
</div>
)}
{/* 月报历史 */}
{report.recent_reports.length > 0 && (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-3 text-lg font-semibold"></h2>
<div className="space-y-3">
{report.recent_reports.map((r, idx) => (
<div key={idx} className="border-b pb-3 last:border-0">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{r.period}</span>
<span className="text-xs text-muted-foreground">{r.status}</span>
</div>
{r.ai_summary && (
<p className="mt-1 text-sm text-muted-foreground">{r.ai_summary}</p>
)}
</div>
))}
</div>
</div>
)}
{/* 风险时间线 */}
<div className="rounded-lg border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-lg font-semibold">线</h2>
<RiskTimeline
risks={report.risks.map((r, idx) => ({
id: String(idx),
title: r.title,
severity: r.severity,
status: r.status,
identified_at: report.generated_at,
}))}
/>
</div>
</div>
);
}
+12 -60
View File
@@ -1,20 +1,18 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { AlertTriangle, Trash2, Filter } from "lucide-react";
import { Filter } from "lucide-react";
import { toast } from "sonner";
import {
listRisks,
updateRisk,
deleteRisk,
RISK_STATUS_LABELS,
RISK_STATUS_COLORS,
SEVERITY_LABELS,
SEVERITY_COLORS,
RISK_TYPE_LABELS,
type RiskEvent,
} from "@/lib/risks";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { RiskCard } from "@/components/risk/RiskCard";
/**
* 投资人端 — 风险工作台。
@@ -53,19 +51,20 @@ export default function RisksPage() {
async function handleStatusChange(id: string, newStatus: string) {
try {
await updateRisk(id, { status: newStatus });
toast.success("状态已更新");
loadRisks();
} catch (err) {
alert(err instanceof Error ? err.message : "更新失败");
toast.error(err instanceof Error ? err.message : "更新失败");
}
}
async function handleDelete(id: string) {
if (!confirm("确认删除该风险事件?")) return;
try {
await deleteRisk(id);
toast.success("风险事件已删除");
loadRisks();
} catch (err) {
alert(err instanceof Error ? err.message : "删除失败");
toast.error(err instanceof Error ? err.message : "删除失败");
}
}
@@ -103,59 +102,12 @@ export default function RisksPage() {
) : (
<div className="space-y-3">
{risks.map((risk) => (
<div
<RiskCard
key={risk.id}
className="rounded-lg border bg-white p-4 shadow-sm"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<AlertTriangle
size={18}
className={SEVERITY_COLORS[risk.severity] || "text-muted-foreground"}
aria-hidden="true"
/>
<h3 className="font-semibold text-foreground">{risk.title}</h3>
<span className={`rounded-full px-2 py-0.5 text-xs ${RISK_STATUS_COLORS[risk.status] || ""}`}>
{RISK_STATUS_LABELS[risk.status] || risk.status}
</span>
</div>
<div className="mt-2 flex gap-3 text-xs text-muted-foreground">
<span>{RISK_TYPE_LABELS[risk.type] || risk.type}</span>
<span className={SEVERITY_COLORS[risk.severity]}>
: {SEVERITY_LABELS[risk.severity] || risk.severity}
</span>
<span>{new Date(risk.identified_at).toLocaleDateString("zh-CN")}</span>
</div>
{risk.description && (
<p className="mt-2 text-sm text-muted-foreground">{risk.description}</p>
)}
{risk.suggested_action && (
<p className="mt-1 text-sm text-blue-600">
{risk.suggested_action}
</p>
)}
</div>
<div className="flex items-center gap-2">
<select
value={risk.status}
onChange={(e) => handleStatusChange(risk.id, e.target.value)}
className="rounded-md border bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-[var(--investor-primary)]"
>
{Object.entries(RISK_STATUS_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<button
onClick={() => handleDelete(risk.id)}
className="rounded p-1.5 text-[var(--destructive)] hover:bg-rose-50"
aria-label="删除"
>
<Trash2 size={16} aria-hidden="true" />
</button>
</div>
</div>
</div>
risk={risk}
onStatusChange={handleStatusChange}
onDelete={handleDelete}
/>
))}
</div>
)}
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { Building2, ArrowRight } from "lucide-react";
import { HealthScoreBadge } from "@/components/shared/HealthScoreBadge";
import type { Company } from "@/lib/companies";
/** 企业卡片组件 — 从企业列表页抽取的独立组件。 */
interface CompanyCardProps {
company: Company;
}
/**
* 企业卡片。
*/
export function CompanyCard({ company }: CompanyCardProps) {
return (
<Link
href={`/companies/${company.id}`}
className="group rounded-lg border bg-white p-5 shadow-sm transition-all hover:shadow-md"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--investor-primary)]/10">
<Building2 className="text-[var(--investor-primary)]" size={20} aria-hidden="true" />
</div>
<div>
<h3 className="font-semibold text-foreground group-hover:text-[var(--investor-primary)]">
{company.name}
</h3>
<p className="text-xs text-muted-foreground">{company.industry || "未分类"}</p>
</div>
</div>
<HealthScoreBadge score={75} />
</div>
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
{company.description || "暂无描述"}
</p>
<div className="mt-4 flex items-center justify-between border-t pt-3">
<div className="flex gap-2">
{company.stage && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{company.stage.toUpperCase()}
</span>
)}
{company.total_funding && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{company.total_funding}
</span>
)}
</div>
<ArrowRight
size={16}
className="text-muted-foreground transition-transform group-hover:translate-x-1"
aria-hidden="true"
/>
</div>
</Link>
);
}
@@ -0,0 +1,122 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { Sparkles, Loader2, FileText } from "lucide-react";
/** AI 周报摘要组件 — SSE 流式生成投资组合周报。 */
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
/**
* AI 周报摘要组件。
* 点击按钮后通过 SSE 流式生成本周投资组合摘要。
*/
export function AIWeeklyBrief() {
const [isGenerating, setIsGenerating] = useState(false);
const [content, setContent] = useState("");
const [error, setError] = useState("");
const abortRef = useRef<AbortController | null>(null);
const generate = useCallback(async () => {
setIsGenerating(true);
setContent("");
setError("");
const abortController = new AbortController();
abortRef.current = abortController;
try {
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
const response = await fetch(`${API_BASE}/copilot/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
message: "请生成本周投资组合摘要,包括:1. 整体健康度变化 2. 新增风险事件 3. 需要关注的企业 4. 建议行动项",
}),
signal: abortController.signal,
});
if (!response.ok) throw new Error("请求失败");
const reader = response.body?.getReader();
if (!reader) throw new Error("无法读取流");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const dataStr = line.slice(6);
try {
const event = JSON.parse(dataStr);
if (event.type === "token") {
setContent((prev) => prev + event.content);
} else if (event.type === "error") {
setError(event.message);
}
} catch {
// ignore
}
}
}
} catch (err) {
if (err instanceof Error && err.name !== "AbortError") {
setError(err instanceof Error ? err.message : "生成失败");
}
} finally {
setIsGenerating(false);
}
}, []);
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
return (
<div className="rounded-lg border bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles size={18} className="text-[var(--investor-primary)]" aria-hidden="true" />
<h2 className="text-lg font-semibold">AI </h2>
</div>
<button
onClick={generate}
disabled={isGenerating}
className="flex items-center gap-2 rounded-md bg-[var(--investor-primary)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
>
{isGenerating ? (
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
) : (
<FileText size={14} aria-hidden="true" />
)}
{isGenerating ? "生成中..." : "生成周报"}
</button>
</div>
{error && (
<p className="mb-3 text-sm text-rose-600">{error}</p>
)}
{content ? (
<div className="whitespace-pre-wrap text-sm text-foreground">
{content}
</div>
) : !isGenerating && !error ? (
<p className="py-4 text-center text-sm text-muted-foreground">
"生成周报" AI
</p>
) : null}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { AlertTriangle, Trash2 } from "lucide-react";
import {
RISK_STATUS_LABELS,
RISK_STATUS_COLORS,
SEVERITY_LABELS,
SEVERITY_COLORS,
RISK_TYPE_LABELS,
type RiskEvent,
} from "@/lib/risks";
/** 风险卡片组件 — 从风险工作台抽取的独立组件。 */
interface RiskCardProps {
risk: RiskEvent;
onStatusChange: (id: string, status: string) => void;
onDelete: (id: string) => void;
}
/**
* 风险事件卡片。
*/
export function RiskCard({ risk, onStatusChange, onDelete }: RiskCardProps) {
return (
<div className="rounded-lg border bg-white p-4 shadow-sm">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<AlertTriangle
size={18}
className={SEVERITY_COLORS[risk.severity] || "text-muted-foreground"}
aria-hidden="true"
/>
<h3 className="font-semibold text-foreground">{risk.title}</h3>
<span className={`rounded-full px-2 py-0.5 text-xs ${RISK_STATUS_COLORS[risk.status] || ""}`}>
{RISK_STATUS_LABELS[risk.status] || risk.status}
</span>
</div>
<div className="mt-2 flex gap-3 text-xs text-muted-foreground">
<span>{RISK_TYPE_LABELS[risk.type] || risk.type}</span>
<span className={SEVERITY_COLORS[risk.severity]}>
: {SEVERITY_LABELS[risk.severity] || risk.severity}
</span>
<span>{new Date(risk.identified_at).toLocaleDateString("zh-CN")}</span>
</div>
{risk.description && (
<p className="mt-2 text-sm text-muted-foreground">{risk.description}</p>
)}
{risk.suggested_action && (
<p className="mt-1 text-sm text-blue-600">
{risk.suggested_action}
</p>
)}
</div>
<div className="flex items-center gap-2">
<select
value={risk.status}
onChange={(e) => onStatusChange(risk.id, e.target.value)}
className="rounded-md border bg-background px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-[var(--investor-primary)]"
>
{Object.entries(RISK_STATUS_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<button
onClick={() => onDelete(risk.id)}
className="rounded p-1.5 text-[var(--destructive)] hover:bg-rose-50"
aria-label="删除"
>
<Trash2 size={16} aria-hidden="true" />
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useMemo } from "react";
/** 风险时间线组件 — 按时间排列风险事件。 */
interface RiskTimelineProps {
risks: Array<{
id: string;
title: string;
severity: string;
status: string;
identified_at: string;
}>;
}
const SEVERITY_DOT_COLORS: Record<string, string> = {
low: "bg-emerald-500",
medium: "bg-amber-500",
high: "bg-orange-500",
critical: "bg-rose-500",
};
/**
* 风险时间线(纯 CSS 竖线 + 圆点)。
*/
export function RiskTimeline({ risks }: RiskTimelineProps) {
const sorted = useMemo(
() => [...risks].sort((a, b) =>
new Date(b.identified_at).getTime() - new Date(a.identified_at).getTime()
),
[risks]
);
if (risks.length === 0) {
return <p className="py-4 text-center text-sm text-muted-foreground"></p>;
}
return (
<div className="relative pl-6">
{/* 竖线 */}
<div className="absolute left-2 top-0 bottom-0 w-px bg-border" aria-hidden="true" />
<div className="space-y-4">
{sorted.map((risk) => (
<div key={risk.id} className="relative">
{/* 圆点 */}
<div
className={`absolute -left-4 top-1 h-3 w-3 rounded-full ${SEVERITY_DOT_COLORS[risk.severity] || "bg-muted"}`}
aria-hidden="true"
/>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">{risk.title}</span>
<span className="text-xs text-muted-foreground">
{new Date(risk.identified_at).toLocaleDateString("zh-CN")}
</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{risk.severity} · {risk.status}
</p>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { AlertTriangle, X, CheckCircle } from "lucide-react";
/** 风险预警 toast 推送组件 — 轮询新风险并弹出 toast。 */
import { listRisks, type RiskEvent } from "@/lib/risks";
const POLL_INTERVAL = 30000; // 30 秒轮询
interface ToastItem {
id: string;
title: string;
severity: string;
}
/**
* 风险预警 toast 推送。
* 定期轮询新风险事件,弹出 toast 通知。
*/
export function RiskToastNotifier() {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const lastRiskIds = useRef<Set<string>>(new Set());
const initialized = useRef(false);
useEffect(() => {
async function poll() {
try {
const resp = await listRisks({ page: 1, page_size: 5, status: "open" });
if (!resp.data) return;
const newRisks: ToastItem[] = [];
for (const risk of resp.data.items) {
if (!lastRiskIds.current.has(risk.id)) {
if (initialized.current) {
newRisks.push({
id: risk.id,
title: risk.title,
severity: risk.severity,
});
}
lastRiskIds.current.add(risk.id);
}
}
initialized.current = true;
if (newRisks.length > 0) {
setToasts((prev) => [...prev, ...newRisks]);
// 5 秒后自动消失
for (const toast of newRisks) {
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== toast.id));
}, 5000);
}
}
} catch {
// 静默失败
}
}
poll();
const interval = setInterval(poll, POLL_INTERVAL);
return () => clearInterval(interval);
}, []);
function dismiss(id: string) {
setToasts((prev) => prev.filter((t) => t.id !== id));
}
if (toasts.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-[60] space-y-2 no-print">
{toasts.map((toast) => (
<div
key={toast.id}
className={`flex items-start gap-3 rounded-lg border bg-white p-4 shadow-lg ${
toast.severity === "critical" ? "border-rose-300" :
toast.severity === "high" ? "border-orange-300" :
"border-amber-300"
}`}
>
<AlertTriangle
size={20}
className={
toast.severity === "critical" ? "text-rose-600" :
toast.severity === "high" ? "text-orange-600" :
"text-amber-600"
}
aria-hidden="true"
/>
<div className="flex-1">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-sm text-muted-foreground">{toast.title}</p>
</div>
<button
onClick={() => dismiss(toast.id)}
className="rounded p-0.5 text-muted-foreground hover:bg-muted"
aria-label="关闭"
>
<X size={14} aria-hidden="true" />
</button>
</div>
))}
</div>
);
}