docs(uiux): UIUX 设计方案大改 + 5 份作业指导书对齐 + 开发任务文档
- UIUX 文档:填充 19 个缺口(多主体画像/健康度/AI+看板/增长域/洞察域/创始人端/OODA/助推/商密) - UIUX 文档:插入 6 个新章节(十四~十九),旧章节重编号为二十~三十一,更新目录和交叉引用 - 作业指导书 x5:导航改为 6 域分组,新增 Context Bar/工作模式/Insight Rail/决策线程/多工作区等 UI 概念 - 新建 docs/2-task-uiux.md:50 个代码落地开发任务,按 P0-P6 分优先级 + 8 Sprint 规划 - 后端/前端:大量新增模型、路由、组件(来自之前 Phase 开发)
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Target, ArrowRight, DollarSign, Users, Trophy } from "lucide-react";
|
||||
|
||||
/** 客户获取方案卡片 — 展示切入角度/决策链/定价/竞争分析。 */
|
||||
export function PlanCard({ plan }: { plan: any }) {
|
||||
const statusColors: Record<string, string> = {
|
||||
planned: "bg-blue-100 text-blue-700",
|
||||
executing: "bg-amber-100 text-amber-700",
|
||||
completed: "bg-emerald-100 text-emerald-700",
|
||||
failed: "bg-rose-100 text-rose-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4 shadow-sm">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">{plan.target_customer || "未指定目标客户"}</h3>
|
||||
</div>
|
||||
<span className={`rounded px-2 py-0.5 text-xs ${statusColors[plan.execution_status] || "bg-muted"}`}>
|
||||
{plan.execution_status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 切入角度 */}
|
||||
{plan.entry_angle && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ArrowRight size={12} />
|
||||
<span>切入角度</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{plan.entry_angle}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 定价策略 */}
|
||||
{plan.pricing_strategy && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<DollarSign size={12} />
|
||||
<span>定价策略</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{plan.pricing_strategy}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 决策链 */}
|
||||
{plan.decision_chain && Array.isArray(plan.decision_chain) && plan.decision_chain.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Users size={12} />
|
||||
<span>决策链</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{plan.decision_chain.map((node: any, i: number) => (
|
||||
<span key={i} className="rounded-md bg-muted px-2 py-1 text-xs">
|
||||
{node.role || "角色"}
|
||||
{node.influence && ` · ${node.influence}`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 竞争分析 */}
|
||||
{plan.competitive_analysis && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Trophy size={12} />
|
||||
<span>竞争分析</span>
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-2 text-xs">
|
||||
{plan.competitive_analysis.strengths && (
|
||||
<div>
|
||||
<div className="font-medium text-emerald-600">优势</div>
|
||||
<ul className="ml-3 list-disc">
|
||||
{plan.competitive_analysis.strengths.map((s: string, i: number) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{plan.competitive_analysis.weaknesses && (
|
||||
<div>
|
||||
<div className="font-medium text-rose-600">劣势</div>
|
||||
<ul className="ml-3 list-disc">
|
||||
{plan.competitive_analysis.weaknesses.map((w: string, i: number) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LP 资源 */}
|
||||
{plan.lp_resources && Array.isArray(plan.lp_resources) && plan.lp_resources.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs text-muted-foreground">可利用 LP 资源</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{plan.lp_resources.map((r: string, i: number) => (
|
||||
<span key={i} className="rounded bg-[var(--investor-primary)]/10 px-2 py-0.5 text-xs text-[var(--investor-primary)]">
|
||||
{r}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { TrendingUp, TrendingDown, Minus, AlertCircle } from "lucide-react";
|
||||
|
||||
/** 预测趋势图组件 — 含置信区间。 */
|
||||
export function ForecastChart({ companyId }: { companyId?: string }) {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const path = companyId
|
||||
? `/dashboard/forecasts?company_id=${companyId}&months_ahead=3`
|
||||
: "/dashboard/forecasts?months_ahead=3";
|
||||
apiFetch<any>(path)
|
||||
.then((res) => setData(res.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [companyId]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (!data) return <EmptyState title="暂无预测数据" />;
|
||||
|
||||
const predictions: number[] = data.predictions || [];
|
||||
const trendDir = data.trend_direction || "stable";
|
||||
const TrendIcon = trendDir === "up" ? TrendingUp : trendDir === "down" ? TrendingDown : Minus;
|
||||
const trendColor = trendDir === "up" ? "text-emerald-600" : trendDir === "down" ? "text-rose-600" : "text-muted-foreground";
|
||||
const anomalies = data.anomalies || {};
|
||||
const anomalyKeys = Object.keys(anomalies);
|
||||
|
||||
const width = 500;
|
||||
const height = 200;
|
||||
const padding = { top: 20, right: 20, bottom: 30, left: 40 };
|
||||
const chartW = width - padding.left - padding.right;
|
||||
const chartH = height - padding.top - padding.bottom;
|
||||
|
||||
const allValues = predictions.length > 0 ? predictions : [];
|
||||
const maxVal = 100;
|
||||
const minVal = 0;
|
||||
const yScale = (v: number) => chartH - ((v - minVal) / (maxVal - minVal)) * chartH;
|
||||
const xStep = predictions.length > 1 ? chartW / (predictions.length - 1) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">健康度预测</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">置信度: {(data.confidence * 100).toFixed(0)}%</span>
|
||||
<TrendIcon size={16} className={trendColor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{predictions.length > 0 ? (
|
||||
<svg width={width} height={height} role="img" aria-label="健康度预测趋势图">
|
||||
{[0, 25, 50, 75, 100].map((v) => (
|
||||
<g key={v}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={padding.top + yScale(v)}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + yScale(v)}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={v === 0 ? "none" : "2,2"}
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 8}
|
||||
y={padding.top + yScale(v) + 4}
|
||||
textAnchor="end"
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
>
|
||||
{v}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* 预测折线 */}
|
||||
<path
|
||||
d={predictions.map((p, i) => `${i === 0 ? "M" : "L"} ${padding.left + i * xStep} ${padding.top + yScale(p)}`).join(" ")}
|
||||
fill="none"
|
||||
stroke="var(--investor-primary)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4,2"
|
||||
/>
|
||||
|
||||
{/* 数据点 */}
|
||||
{predictions.map((p, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={padding.left + i * xStep} cy={padding.top + yScale(p)} r={4} fill="var(--investor-primary)" />
|
||||
<text
|
||||
x={padding.left + i * xStep}
|
||||
y={padding.top + yScale(p) - 10}
|
||||
textAnchor="middle"
|
||||
className="text-[10px] fill-foreground font-medium"
|
||||
>
|
||||
{p.toFixed(1)}
|
||||
</text>
|
||||
<text
|
||||
x={padding.left + i * xStep}
|
||||
y={height - padding.bottom + 16}
|
||||
textAnchor="middle"
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
>
|
||||
+{i + 1}月
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
历史数据不足(需 ≥ 2 期评分),暂无法预测
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 异常检测 */}
|
||||
{anomalyKeys.length > 0 && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-2">
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-amber-700">
|
||||
<AlertCircle size={12} />
|
||||
检测到指标异常
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{anomalyKeys.map((key) => (
|
||||
<span key={key} className="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700">
|
||||
{key} (异常点: {anomalies[key].join(", ")})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
import { DIMENSIONS_14 } from "@/components/health/HealthRadar";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
|
||||
/** 健康度热力图 — 企业 × 维度评分矩阵。 */
|
||||
export function HealthHeatmap() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<any[]>("/dashboard/heatmap")
|
||||
.then((res) => setData((res.data as any[]) || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (!data.length) return <EmptyState title="暂无热力图数据" />;
|
||||
|
||||
const dims = DIMENSIONS_14;
|
||||
|
||||
/** 根据分数返回背景色。 */
|
||||
const getColor = (score: number | null | undefined) => {
|
||||
if (score == null) return "bg-muted";
|
||||
if (score >= 75) return "bg-emerald-200";
|
||||
if (score >= 60) return "bg-emerald-100";
|
||||
if (score >= 50) return "bg-amber-100";
|
||||
if (score >= 40) return "bg-amber-200";
|
||||
return "bg-rose-200";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-white px-2 py-1 text-left font-medium">企业</th>
|
||||
<th className="px-2 py-1 text-center font-medium">总分</th>
|
||||
{dims.map((d) => (
|
||||
<th key={d.key} className="px-1 py-1 text-center font-medium" title={d.label}>
|
||||
{d.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => (
|
||||
<tr key={row.company_id} className="border-t border-[var(--border)]">
|
||||
<td className="sticky left-0 z-10 bg-white px-2 py-1 font-medium">
|
||||
{row.company_name}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-center font-bold">
|
||||
{row.total_score != null ? row.total_score.toFixed(0) : "-"}
|
||||
</td>
|
||||
{dims.map((d) => {
|
||||
const val = row.scores?.[d.key];
|
||||
return (
|
||||
<td key={d.key} className="px-1 py-1 text-center">
|
||||
<div
|
||||
className={`mx-auto flex h-8 w-8 items-center justify-center rounded ${getColor(val)}`}
|
||||
title={`${row.company_name} - ${d.label}: ${val ?? "N/A"}`}
|
||||
>
|
||||
{val != null ? val.toFixed(0) : "-"}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 健康度趋势对比图 — 按月汇总评分变化折线图。 */
|
||||
export function HealthTrends({ companyId }: { companyId?: string }) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const path = companyId
|
||||
? `/dashboard/trends?company_id=${companyId}&months=6`
|
||||
: "/dashboard/trends?months=6";
|
||||
apiFetch<any[]>(path)
|
||||
.then((res) => setData((res.data as any[]) || []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [companyId]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (!data.length) return <EmptyState title="暂无趋势数据" />;
|
||||
|
||||
const width = 600;
|
||||
const height = 240;
|
||||
const padding = { top: 20, right: 20, bottom: 40, left: 40 };
|
||||
const chartW = width - padding.left - padding.right;
|
||||
const chartH = height - padding.top - padding.bottom;
|
||||
|
||||
const periods = data.map((d) => d.period);
|
||||
const scores = data.map((d) => d.avg_score);
|
||||
const maxScore = 100;
|
||||
const minScore = 0;
|
||||
|
||||
const xStep = periods.length > 1 ? chartW / (periods.length - 1) : 0;
|
||||
const yScale = (val: number) => chartH - ((val - minScore) / (maxScore - minScore)) * chartH;
|
||||
|
||||
const linePath = scores
|
||||
.map((s, i) => `${i === 0 ? "M" : "L"} ${padding.left + i * xStep} ${padding.top + yScale(s)}`)
|
||||
.join(" ");
|
||||
|
||||
const prevScore = scores.length > 1 ? scores[scores.length - 2] : null;
|
||||
const latestScore = scores[scores.length - 1];
|
||||
const trendDir = prevScore != null
|
||||
? latestScore > prevScore + 2 ? "up" : latestScore < prevScore - 2 ? "down" : "stable"
|
||||
: "stable";
|
||||
const TrendIcon = trendDir === "up" ? TrendingUp : trendDir === "down" ? TrendingDown : Minus;
|
||||
const trendColor = trendDir === "up" ? "text-emerald-600" : trendDir === "down" ? "text-rose-600" : "text-muted-foreground";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">健康度趋势</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-[var(--investor-primary)]">
|
||||
{latestScore?.toFixed(1)}
|
||||
</span>
|
||||
<TrendIcon size={18} className={trendColor} />
|
||||
</div>
|
||||
</div>
|
||||
<svg width={width} height={height} role="img" aria-label="健康度趋势图">
|
||||
{/* Y 轴刻度 */}
|
||||
{[0, 25, 50, 75, 100].map((v) => (
|
||||
<g key={v}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
y1={padding.top + yScale(v)}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + yScale(v)}
|
||||
stroke="var(--border)"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={v === 0 ? "none" : "2,2"}
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 8}
|
||||
y={padding.top + yScale(v) + 4}
|
||||
textAnchor="end"
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
>
|
||||
{v}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* 折线 */}
|
||||
<path d={linePath} fill="none" stroke="var(--investor-primary)" strokeWidth={2} />
|
||||
{/* 数据点 */}
|
||||
{scores.map((s, i) => (
|
||||
<g key={i}>
|
||||
<circle
|
||||
cx={padding.left + i * xStep}
|
||||
cy={padding.top + yScale(s)}
|
||||
r={4}
|
||||
fill="var(--investor-primary)"
|
||||
/>
|
||||
<text
|
||||
x={padding.left + i * xStep}
|
||||
y={padding.top + yScale(s) - 10}
|
||||
textAnchor="middle"
|
||||
className="text-[10px] fill-foreground font-medium"
|
||||
>
|
||||
{s.toFixed(1)}
|
||||
</text>
|
||||
<text
|
||||
x={padding.left + i * xStep}
|
||||
y={height - padding.bottom + 16}
|
||||
textAnchor="middle"
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
>
|
||||
{periods[i]}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { DollarSign, TrendingUp, Users, MessageSquare } from "lucide-react";
|
||||
|
||||
/** 融资规划组件 — 节奏/估值/投资人画像。 */
|
||||
export function FinancingPlanner() {
|
||||
const [companyData, setCompanyData] = useState("");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!companyData.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>("/founder/financing-plan", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_data: companyData }),
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">融资规划</h3>
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="描述企业当前融资情况、营收、阶段..."
|
||||
value={companyData}
|
||||
onChange={(e) => setCompanyData(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !companyData.trim()}
|
||||
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "生成中..." : "生成融资规划"}
|
||||
</button>
|
||||
{loading && <div className="mt-2"><LoadingSpinner /></div>}
|
||||
{result && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{result.round && <div><span className="text-muted-foreground">轮次:</span>{result.round}</div>}
|
||||
{result.target_amount && <div><span className="text-muted-foreground">目标金额:</span>{result.target_amount}</div>}
|
||||
{result.valuation_range && <div><span className="text-muted-foreground">估值范围:</span>{result.valuation_range}</div>}
|
||||
{result.timeline && <div><span className="text-muted-foreground">时间节奏:</span>{result.timeline}</div>}
|
||||
{result.target_investors && Array.isArray(result.target_investors) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">目标投资人画像:</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{result.target_investors.map((inv: string, i: number) => (
|
||||
<span key={i} className="rounded bg-[var(--investor-primary)]/10 px-2 py-0.5 text-xs text-[var(--investor-primary)]">{inv}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.key_metrics && Array.isArray(result.key_metrics) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">关键指标:</span>
|
||||
<ul className="ml-4 list-disc text-xs">
|
||||
{result.key_metrics.map((m: string, i: number) => <li key={i}>{m}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 组织诊断组件 — 团队结构/关键岗位风险/人才缺口。 */
|
||||
export function OrgDiagnostic() {
|
||||
const [teamData, setTeamData] = useState("");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!teamData.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>("/founder/org-diagnostic", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ team_data: teamData }),
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">组织诊断</h3>
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="描述团队规模、关键岗位、流失情况..."
|
||||
value={teamData}
|
||||
onChange={(e) => setTeamData(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !teamData.trim()}
|
||||
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "诊断中..." : "开始组织诊断"}
|
||||
</button>
|
||||
{loading && <div className="mt-2"><LoadingSpinner /></div>}
|
||||
{result && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{result.structure_assessment && (
|
||||
<div><span className="text-muted-foreground">结构评估:</span>{result.structure_assessment}</div>
|
||||
)}
|
||||
{result.key_role_risks && Array.isArray(result.key_role_risks) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">关键岗位风险:</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{result.key_role_risks.map((r: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-md border border-[var(--border)] px-2 py-1 text-xs">
|
||||
<span>{r.role}</span>
|
||||
<span className={r.severity === "high" ? "text-rose-600" : r.severity === "medium" ? "text-amber-600" : "text-muted-foreground"}>
|
||||
{r.risk}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.talent_gaps && Array.isArray(result.talent_gaps) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">人才缺口:</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{result.talent_gaps.map((g: string, i: number) => (
|
||||
<span key={i} className="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700">{g}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.recommendations && Array.isArray(result.recommendations) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">建议:</span>
|
||||
<ul className="ml-4 list-disc text-xs">
|
||||
{result.recommendations.map((r: string, i: number) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 投资人沟通准备组件 — 董事会材料/投资人问答。 */
|
||||
export function InvestorComm() {
|
||||
const [boardContext, setBoardContext] = useState("");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!boardContext.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>("/founder/investor-comm-prep", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ board_context: boardContext }),
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">投资人沟通准备</h3>
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="描述董事会/投资人会议背景、需要汇报的内容..."
|
||||
value={boardContext}
|
||||
onChange={(e) => setBoardContext(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || !boardContext.trim()}
|
||||
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "准备中..." : "生成沟通材料"}
|
||||
</button>
|
||||
{loading && <div className="mt-2"><LoadingSpinner /></div>}
|
||||
{result && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{result.board_material_outline && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">董事会材料大纲:</span>
|
||||
<p className="mt-1 whitespace-pre-wrap">{result.board_material_outline}</p>
|
||||
</div>
|
||||
)}
|
||||
{result.anticipated_questions && Array.isArray(result.anticipated_questions) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">预期问题与建议回答:</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{result.anticipated_questions.map((q: any, i: number) => (
|
||||
<div key={i} className="rounded-md border border-[var(--border)] px-2 py-1 text-xs">
|
||||
<div className="font-medium">Q: {q.question}</div>
|
||||
<div className="mt-1 text-muted-foreground">A: {q.suggested_answer}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result.key_updates && Array.isArray(result.key_updates) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">关键进展:</span>
|
||||
<ul className="ml-4 list-disc text-xs">
|
||||
{result.key_updates.map((u: string, i: number) => <li key={i}>{u}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{result.asks && Array.isArray(result.asks) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">需要投资人支持的请求:</span>
|
||||
<ul className="ml-4 list-disc text-xs">
|
||||
{result.asks.map((a: string, i: number) => <li key={i}>{a}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
|
||||
/** 健康度雷达图组件 — 四维评分雷达图。 */
|
||||
/** 健康度雷达图组件 — 支持 4/9/14 维度。 */
|
||||
|
||||
interface HealthRadarProps {
|
||||
scores: {
|
||||
financial: number;
|
||||
operational: number;
|
||||
ai_commercial: number;
|
||||
ai_cost: number;
|
||||
};
|
||||
scores: Record<string, number | null | undefined>;
|
||||
dimensions: { key: string; label: string }[];
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
financial: "财务",
|
||||
operational: "经营",
|
||||
ai_commercial: "AI 商业化",
|
||||
ai_cost: "AI 成本",
|
||||
};
|
||||
/** 14 维度定义。 */
|
||||
export const DIMENSIONS_14 = [
|
||||
{ key: "financial_score", label: "财务" },
|
||||
{ key: "operational_score", label: "经营" },
|
||||
{ key: "ai_commercial_score", label: "AI商业化" },
|
||||
{ key: "ai_cost_score", label: "AI成本" },
|
||||
{ key: "org_talent_score", label: "组织人才" },
|
||||
{ key: "product_tech_score", label: "产品技术" },
|
||||
{ key: "market_compete_score", label: "市场竞争" },
|
||||
{ key: "governance_score", label: "治理合规" },
|
||||
{ key: "financing_score", label: "融资资本" },
|
||||
{ key: "synergy_score", label: "协同赋能" },
|
||||
{ key: "ai_model_product_score", label: "AI模型产品" },
|
||||
{ key: "data_compliance_score", label: "数据合规" },
|
||||
{ key: "team_tech_score", label: "团队技术" },
|
||||
{ key: "customer_success_score", label: "客户成功" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 健康度雷达图(纯 SVG,无额外依赖)。
|
||||
*/
|
||||
export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
const center = size / 2;
|
||||
const radius = size / 2 - 40;
|
||||
const axes = Object.keys(LABELS);
|
||||
const angleStep = (Math.PI * 2) / axes.length;
|
||||
/** 9 维度定义(T2.9)。 */
|
||||
export const DIMENSIONS_9 = DIMENSIONS_14.slice(0, 9);
|
||||
|
||||
/** 基础 4 维度定义。 */
|
||||
export const DIMENSIONS_4 = DIMENSIONS_14.slice(0, 4);
|
||||
|
||||
/** 健康度雷达图(纯 SVG,支持动态维度数)。 */
|
||||
export function HealthRadar({ scores, dimensions, size = 280 }: HealthRadarProps) {
|
||||
const validDims = dimensions.filter((d) => scores[d.key] != null);
|
||||
|
||||
const { gridLevels, axisLines, dataPoints, labelPositions } = useMemo(() => {
|
||||
const center = size / 2;
|
||||
const radius = size / 2 - 50;
|
||||
const numDims = validDims.length;
|
||||
if (numDims < 3) return { gridLevels: [], axisLines: [], dataPoints: [], labelPositions: [] };
|
||||
|
||||
const angleStep = (Math.PI * 2) / numDims;
|
||||
const levels = [0.25, 0.5, 0.75, 1.0];
|
||||
|
||||
const grid = levels.map((level) =>
|
||||
axes.map((_, i) => {
|
||||
validDims.map((_, i) => {
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
return {
|
||||
x: center + Math.cos(angle) * radius * level,
|
||||
@@ -42,7 +58,7 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
})
|
||||
);
|
||||
|
||||
const axis = axes.map((_, i) => {
|
||||
const axis = validDims.map((_, i) => {
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
return {
|
||||
x: center + Math.cos(angle) * radius,
|
||||
@@ -50,8 +66,8 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const values = [scores.financial, scores.operational, scores.ai_commercial, scores.ai_cost];
|
||||
const points = values.map((val, i) => {
|
||||
const points = validDims.map((dim, i) => {
|
||||
const val = scores[dim.key] ?? 0;
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
const r = (val / 100) * radius;
|
||||
return {
|
||||
@@ -60,23 +76,30 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const labels = axes.map((key, i) => {
|
||||
const labels = validDims.map((dim, i) => {
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
return {
|
||||
x: center + Math.cos(angle) * (radius + 20),
|
||||
y: center + Math.sin(angle) * (radius + 20),
|
||||
label: LABELS[key],
|
||||
x: center + Math.cos(angle) * (radius + 25),
|
||||
y: center + Math.sin(angle) * (radius + 25),
|
||||
label: dim.label,
|
||||
};
|
||||
});
|
||||
|
||||
return { gridLevels: grid, axisLines: axis, dataPoints: points, labelPositions: labels };
|
||||
}, [scores, center, radius, angleStep]);
|
||||
}, [scores, validDims, size]);
|
||||
|
||||
if (validDims.length < 3) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
维度数据不足,无法绘制雷达图
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const polygonPoints = dataPoints.map((p) => `${p.x},${p.y}`).join(" ");
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} role="img" aria-label="健康度雷达图">
|
||||
{/* 网格 */}
|
||||
{gridLevels.map((level, idx) => (
|
||||
<polygon
|
||||
key={idx}
|
||||
@@ -87,13 +110,11 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 轴线 */}
|
||||
{axisLines.map((line, idx) => (
|
||||
<line
|
||||
key={idx}
|
||||
x1={center}
|
||||
y1={center}
|
||||
x1={size / 2}
|
||||
y1={size / 2}
|
||||
x2={line.x}
|
||||
y2={line.y}
|
||||
stroke="currentColor"
|
||||
@@ -101,8 +122,6 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 数据区域 */}
|
||||
<polygon
|
||||
points={polygonPoints}
|
||||
fill="var(--investor-primary)"
|
||||
@@ -110,19 +129,9 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
stroke="var(--investor-primary)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
{/* 数据点 */}
|
||||
{dataPoints.map((p, idx) => (
|
||||
<circle
|
||||
key={idx}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={4}
|
||||
fill="var(--investor-primary)"
|
||||
/>
|
||||
<circle key={idx} cx={p.x} cy={p.y} r={3} fill="var(--investor-primary)" />
|
||||
))}
|
||||
|
||||
{/* 标签 */}
|
||||
{labelPositions.map((lp, idx) => (
|
||||
<text
|
||||
key={idx}
|
||||
@@ -130,7 +139,7 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
y={lp.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-xs fill-muted-foreground"
|
||||
className="text-[10px] fill-muted-foreground"
|
||||
>
|
||||
{lp.label}
|
||||
</text>
|
||||
@@ -138,3 +147,57 @@ export function HealthRadar({ scores, size = 240 }: HealthRadarProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 健康度维度详情面板 — 评分依据/扣分项/建议。 */
|
||||
export function HealthDimensionDetail({
|
||||
scores,
|
||||
dimensions,
|
||||
}: {
|
||||
scores: Record<string, number | null | undefined>;
|
||||
dimensions: { key: string; label: string; description: string }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{dimensions.map((dim) => {
|
||||
const score = scores[dim.key];
|
||||
if (score == null) return null;
|
||||
const color =
|
||||
score >= 75 ? "text-emerald-600" : score >= 50 ? "text-amber-600" : "text-rose-600";
|
||||
const TrendIcon = score >= 70 ? TrendingUp : score < 40 ? TrendingDown : Minus;
|
||||
return (
|
||||
<div
|
||||
key={dim.key}
|
||||
className="flex items-center justify-between rounded-md border border-[var(--border)] px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{dim.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{dim.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-lg font-bold ${color}`}>{score.toFixed(0)}</span>
|
||||
<TrendIcon size={16} className={color} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 14 维度详情定义(含描述)。 */
|
||||
export const DIMENSIONS_14_DETAIL = [
|
||||
{ key: "financial_score", label: "财务", description: "现金跑道、营收增长、烧钱率" },
|
||||
{ key: "operational_score", label: "经营", description: "团队规模、关键指标达成" },
|
||||
{ key: "ai_commercial_score", label: "AI商业化", description: "AI 相关指标商业化" },
|
||||
{ key: "ai_cost_score", label: "AI成本", description: "AI 推理成本效率" },
|
||||
{ key: "org_talent_score", label: "组织人才", description: "流失率、关键岗位" },
|
||||
{ key: "product_tech_score", label: "产品技术", description: "迭代频率、技术指标" },
|
||||
{ key: "market_compete_score", label: "市场竞争", description: "市场份额、客户增长" },
|
||||
{ key: "governance_score", label: "治理合规", description: "董事会、合规事件" },
|
||||
{ key: "financing_score", label: "融资资本", description: "跑道、融资进度" },
|
||||
{ key: "synergy_score", label: "协同赋能", description: "Portfolio 协同" },
|
||||
{ key: "ai_model_product_score", label: "AI模型产品", description: "模型精度、数据质量" },
|
||||
{ key: "data_compliance_score", label: "数据合规", description: "数据合规审计" },
|
||||
{ key: "team_tech_score", label: "团队技术", description: "技术负责人、专利" },
|
||||
{ key: "customer_success_score", label: "客户成功", description: "留存率、NPS" },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { FileText, Printer, Download } from "lucide-react";
|
||||
|
||||
/** 报告预览组件 — 浏览器打印优化。 */
|
||||
export function ReportPreview({ report, companyName }: { report: any; companyName: string }) {
|
||||
if (!report) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between no-print">
|
||||
<h3 className="text-sm font-medium">报告预览</h3>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-1 rounded-md border border-[var(--border)] px-3 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<Printer size={14} />
|
||||
打印
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-6 print:border-0 print:shadow-none">
|
||||
{/* 封面 */}
|
||||
<div className="mb-6 border-b pb-4 no-print">
|
||||
<h1 className="text-xl font-bold">{companyName} — 投后管理报告</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
生成时间:{new Date().toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 报告内容 */}
|
||||
{report.executive_summary && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">执行摘要</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.executive_summary}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.financial_performance && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">财务表现</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.financial_performance}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.operational_highlights && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">运营亮点</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.operational_highlights}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.risk_assessment && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">风险评估</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.risk_assessment}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.recommendations && Array.isArray(report.recommendations) && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">建议</h2>
|
||||
<ul className="ml-4 list-disc text-sm">
|
||||
{report.recommendations.map((r: string, i: number) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.next_quarter_focus && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">下季度关注重点</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.next_quarter_focus}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 年度报告字段 */}
|
||||
{report.year_in_review && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">年度回顾</h2>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{report.year_in_review}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report.key_achievements && Array.isArray(report.key_achievements) && (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-sm font-semibold">关键成就</h2>
|
||||
<ul className="ml-4 list-disc text-sm">
|
||||
{report.key_achievements.map((a: string, i: number) => <li key={i}>{a}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 报告模板选择页。 */
|
||||
export function ReportTemplateSelector({ onGenerate }: { onGenerate: (type: string, data: string) => void }) {
|
||||
const [template, setTemplate] = useState("quarterly");
|
||||
const [companyData, setCompanyData] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const templates = [
|
||||
{ id: "quarterly", name: "季度报告", desc: "包含财务表现、运营亮点、风险评估、下季度建议" },
|
||||
{ id: "annual", name: "年度报告", desc: "包含年度回顾、关键成就、Alpha 归因、下年度计划" },
|
||||
];
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!companyData.trim()) return;
|
||||
setGenerating(true);
|
||||
try {
|
||||
onGenerate(template, companyData);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{templates.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTemplate(t.id)}
|
||||
className={`rounded-lg border p-4 text-left transition-all ${
|
||||
template === t.id
|
||||
? "border-[var(--investor-primary)] bg-[var(--investor-primary)]/5"
|
||||
: "border-[var(--border)] hover:border-[var(--investor-primary)]/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={16} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">{t.name}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">企业数据</label>
|
||||
<textarea
|
||||
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
|
||||
rows={5}
|
||||
placeholder="输入企业经营数据、关键指标、风险事件等..."
|
||||
value={companyData}
|
||||
onChange={(e) => setCompanyData(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || !companyData.trim()}
|
||||
className="flex items-center gap-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{generating ? <LoadingSpinner /> : <Download size={14} />}
|
||||
{generating ? "生成中..." : "生成报告"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Clock, TrendingUp } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { EmptyState } from "@/components/shared/EmptyState";
|
||||
|
||||
/**
|
||||
* 月报提交及时性面板 — 延迟热力图 + 数据质量评分卡。
|
||||
*/
|
||||
|
||||
interface TimelinessItem {
|
||||
company_id: string;
|
||||
company_name: string;
|
||||
period_year: number;
|
||||
period_month: number;
|
||||
submitted_at: string;
|
||||
delay_days: number;
|
||||
quality_score: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function TimelinessPanel({ companyId }: { companyId?: string }) {
|
||||
const [items, setItems] = useState<TimelinessItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const path = companyId
|
||||
? `/reports/timeliness?company_id=${companyId}`
|
||||
: "/reports/timeliness";
|
||||
apiFetch<TimelinessItem[]>(path)
|
||||
.then((resp) => setItems(resp.data ?? []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [companyId]);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!items.length) return <EmptyState description="暂无月报提交记录" />;
|
||||
|
||||
const avgDelay = Math.round(
|
||||
items.reduce((s, i) => s + i.delay_days, 0) / items.length,
|
||||
);
|
||||
const avgQuality = Math.round(
|
||||
items.reduce((s, i) => s + i.quality_score, 0) / items.length,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Clock className="h-4 w-4" />
|
||||
平均延迟
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-gray-900">
|
||||
{avgDelay} <span className="text-sm text-gray-400">天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
平均数据质量
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-gray-900">
|
||||
{avgQuality} <span className="text-sm text-gray-400">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-500">企业</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-500">期间</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-500">延迟天数</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-500">数据质量</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-500">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-4 py-2 text-gray-900">{item.company_name}</td>
|
||||
<td className="px-4 py-2 text-gray-600">
|
||||
{item.period_year}-{String(item.period_month).padStart(2, "0")}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={
|
||||
item.delay_days === 0
|
||||
? "text-emerald-600"
|
||||
: item.delay_days <= 3
|
||||
? "text-amber-600"
|
||||
: "text-rose-600"
|
||||
}
|
||||
>
|
||||
{item.delay_days} 天
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={
|
||||
item.quality_score >= 80
|
||||
? "text-emerald-600"
|
||||
: item.quality_score >= 50
|
||||
? "text-amber-600"
|
||||
: "text-rose-600"
|
||||
}
|
||||
>
|
||||
{item.quality_score}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{item.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
|
||||
/**
|
||||
* 路由守卫 — 未登录时重定向到登录页。
|
||||
*
|
||||
* 包裹投资人端和创始人端的受保护页面。
|
||||
*/
|
||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const { token, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !token) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [isLoading, token, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Upload, File as FileIcon, X, Loader2 } from "lucide-react";
|
||||
|
||||
/** 文件上传组件 — 支持拖拽 + 点击上传。 */
|
||||
export function FileUploader({ onParsed }: { onParsed: (result: any) => void }) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const allowedTypes = [".xlsx", ".xls", ".pdf", ".txt", ".md", ".csv"];
|
||||
|
||||
const handleFile = useCallback(async (f: File) => {
|
||||
const ext = f.name.match(/\.[^.]+$/)?.[0]?.toLowerCase() || "";
|
||||
if (!allowedTypes.includes(ext)) {
|
||||
return;
|
||||
}
|
||||
setFile(f);
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", f);
|
||||
const token = localStorage.getItem("token");
|
||||
const resp = await fetch("/api/v1/reports/upload", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
onParsed(data.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [onParsed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border-2 border-dashed border-[var(--border)] p-6 text-center transition-colors"
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
|
||||
}}
|
||||
style={dragging ? { borderColor: "var(--investor-primary)", background: "var(--investor-primary)/5" } : {}}
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-between rounded-md border border-[var(--border)] bg-white px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileIcon size={16} className="text-[var(--investor-primary)]" />
|
||||
<span className="text-sm">{file.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{uploading && <Loader2 size={14} className="animate-spin text-muted-foreground" />}
|
||||
<button
|
||||
onClick={() => { setFile(null); onParsed(null); }}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label="移除文件"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex flex-col items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Upload size={24} />
|
||||
<span>点击或拖拽文件到此处上传</span>
|
||||
<span className="text-xs">支持 .xlsx .pdf .txt .csv .md(最大 10MB)</span>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.pdf,.txt,.md,.csv"
|
||||
className="hidden"
|
||||
onChange={(e) => { if (e.target.files?.[0]) handleFile(e.target.files[0]); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { Search, FileText, Loader2 } from "lucide-react";
|
||||
|
||||
/** 语义搜索组件 — 搜索知识库中的月报/报告片段。 */
|
||||
export function KnowledgeSearch() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
try {
|
||||
const res = await apiFetch<any[]>(`/knowledge/search?q=${encodeURIComponent(query)}&top_k=5`);
|
||||
setResults((res.data as any[]) || []);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-md border border-[var(--border)] py-2 pl-9 pr-3 text-sm"
|
||||
placeholder="语义搜索知识库..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading || !query.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searched && !loading && results.length === 0 && (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">未找到相关内容</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{results.map((r) => (
|
||||
<div key={r.id} className="rounded-lg border border-[var(--border)] bg-white p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={14} className="text-[var(--investor-primary)]" />
|
||||
<span className="rounded bg-[var(--investor-primary)]/10 px-2 py-0.5 text-xs text-[var(--investor-primary)]">
|
||||
{r.source_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-foreground">{r.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/** 通用列表页面布局组件 — 标题 + 描述 + 操作区 + 内容区。 */
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* 通用页面容器。
|
||||
* @param title - 页面标题
|
||||
* @param description - 页面描述
|
||||
* @param actions - 操作按钮区
|
||||
* @param children - 内容区
|
||||
*/
|
||||
export function PageContainer({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-gray-500">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单卡片。
|
||||
*/
|
||||
export function Card({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`rounded-lg border border-gray-200 bg-white p-4 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签徽章。
|
||||
*/
|
||||
export function Badge({ children, color = "gray" }: { children: ReactNode; color?: "gray" | "green" | "amber" | "red" | "blue" }) {
|
||||
const colors: Record<string, string> = {
|
||||
gray: "bg-gray-100 text-gray-700",
|
||||
green: "bg-emerald-100 text-emerald-700",
|
||||
amber: "bg-amber-100 text-amber-700",
|
||||
red: "bg-rose-100 text-rose-700",
|
||||
blue: "bg-blue-100 text-blue-700",
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colors[color]}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 空表格行占位。
|
||||
*/
|
||||
export function TableEmpty({ colSpan, message = "暂无数据" }: { colSpan: number; message?: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-gray-400">
|
||||
{message}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||
import { AlertTriangle, GitBranch, TrendingDown } from "lucide-react";
|
||||
|
||||
/** 约束点面板 — TOC 约束点识别。 */
|
||||
export function ConstraintPanel() {
|
||||
const [companyData, setCompanyData] = useState("");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleAnalyze() {
|
||||
if (!companyData.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>("/advanced-analysis/constraints", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_data: companyData }),
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={18} className="text-amber-500" />
|
||||
<h3 className="text-sm font-medium">TOC 约束点识别</h3>
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="描述企业当前运营数据和瓶颈..."
|
||||
value={companyData}
|
||||
onChange={(e) => setCompanyData(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading || !companyData.trim()}
|
||||
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "分析中..." : "识别约束点"}
|
||||
</button>
|
||||
{loading && <div className="mt-2"><LoadingSpinner /></div>}
|
||||
{result && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{result.constraint && <div><span className="text-muted-foreground">核心约束点:</span>{result.constraint}</div>}
|
||||
{result.sensitivity && <div><span className="text-muted-foreground">敏感度:</span>{result.sensitivity}</div>}
|
||||
{result.improvement_space && <div><span className="text-muted-foreground">改善空间:</span>{result.improvement_space}</div>}
|
||||
{result.recommendations && Array.isArray(result.recommendations) && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">建议:</span>
|
||||
<ul className="ml-4 list-disc text-xs">
|
||||
{result.recommendations.map((r: string, i: number) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** BML 认知追踪组件。 */
|
||||
export function BMLTracker() {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch size={18} className="text-[var(--investor-primary)]" />
|
||||
<h3 className="text-sm font-medium">BML 认知追踪</h3>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2 text-sm text-muted-foreground">
|
||||
<p>假设 → 实验 → 数据 → 结论 循环追踪</p>
|
||||
<p className="text-xs">待月报结构化数据接入后展示</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 鸿沟诊断预警组件。 */
|
||||
export function ChasmAlert() {
|
||||
const [companyData, setCompanyData] = useState("");
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleDiagnose() {
|
||||
if (!companyData.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>("/advanced-analysis/chasm", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ company_data: companyData }),
|
||||
});
|
||||
setResult(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown size={18} className="text-rose-500" />
|
||||
<h3 className="text-sm font-medium">鸿沟诊断预警</h3>
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-2 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
placeholder="描述产品采用情况、客户类型分布..."
|
||||
value={companyData}
|
||||
onChange={(e) => setCompanyData(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleDiagnose}
|
||||
disabled={loading || !companyData.trim()}
|
||||
className="mt-2 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? "诊断中..." : "鸿沟诊断"}
|
||||
</button>
|
||||
{loading && <div className="mt-2"><LoadingSpinner /></div>}
|
||||
{result && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{result.stage && <div><span className="text-muted-foreground">当前阶段:</span>{result.stage}</div>}
|
||||
{result.gap_detected !== undefined && (
|
||||
<div className={`rounded-md px-3 py-1 text-xs ${result.gap_detected ? "bg-rose-100 text-rose-700" : "bg-emerald-100 text-emerald-700"}`}>
|
||||
{result.gap_detected ? "⚠ 检测到鸿沟风险" : "✓ 未检测到鸿沟"}
|
||||
</div>
|
||||
)}
|
||||
{result.crossing_strategy && (
|
||||
<div><span className="text-muted-foreground">跨越策略:</span>{result.crossing_strategy}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
LayoutDashboard, DollarSign, Activity, Users, Bot,
|
||||
AlertTriangle, FileText, ClipboardList, ScrollText, Network,
|
||||
} from "lucide-react";
|
||||
|
||||
/** 工作台 Tab 定义。 */
|
||||
const TABS = [
|
||||
{ id: "overview", label: "概览", icon: LayoutDashboard },
|
||||
{ id: "financial", label: "财务", icon: DollarSign },
|
||||
{ id: "operational", label: "经营", icon: Activity },
|
||||
{ id: "org", label: "组织", icon: Users },
|
||||
{ id: "ai", label: "AI+专项", icon: Bot },
|
||||
{ id: "risk", label: "风险", icon: AlertTriangle },
|
||||
{ id: "reports", label: "月报", icon: FileText },
|
||||
{ id: "board", label: "董事会", icon: ClipboardList },
|
||||
{ id: "agreements", label: "协议", icon: ScrollText },
|
||||
{ id: "synergy", label: "协同", icon: Network },
|
||||
] as const;
|
||||
|
||||
export type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
/** 工作台左侧 Tab 导航组件。 */
|
||||
export function TabNav({
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
active: TabId;
|
||||
onChange: (tab: TabId) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="flex flex-row gap-1 overflow-x-auto border-b border-[var(--border)] md:w-40 md:flex-col md:overflow-y-auto md:border-b-0 md:border-r">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`flex shrink-0 items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ${
|
||||
active === tab.id
|
||||
? "bg-[var(--investor-primary)]/10 font-medium text-[var(--investor-primary)]"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} aria-hidden="true" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** 工作台 Tab 面板容器。 */
|
||||
export function TabPanel({ children }: { children: ReactNode }) {
|
||||
return <div className="flex-1 space-y-4 p-4">{children}</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user