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:
selfrelease
2026-07-19 11:53:38 +08:00
parent 734a16a7f3
commit fad458b2a7
243 changed files with 19898 additions and 658 deletions
@@ -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>
);
}
+112 -49
View File
@@ -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>;
}