feat(frontend): Phase 6 evaluation template config page + score compare view

This commit is contained in:
selfrelease
2026-07-19 21:24:36 +08:00
parent de53a252e4
commit e3ec98f63a
6 changed files with 1151 additions and 3 deletions
@@ -0,0 +1,432 @@
/** 评分对比视图 — 多模板/多企业评分并排对比 + 雷达图。 */
"use client";
import { useState, useEffect, useCallback } from "react";
import { GitCompareArrows, Loader2, TrendingUp, TrendingDown, Minus } from "lucide-react";
import {
listEvaluationTemplates,
listScores,
type EvaluationTemplate,
type ScoreRecord,
} from "@/lib/api-v2";
import {
DIMENSION_LABELS,
DIMENSION_SCORE_KEYS,
FUND_TYPE_LABELS,
STAGE_LABELS,
INDUSTRY_LABELS,
getScoreColor,
getScoreBgColor,
} from "@/lib/evaluation-constants";
/** 维度 key 列表(14 维度)。 */
const ALL_DIMENSIONS = Object.keys(DIMENSION_LABELS);
/** 趋势图标。 */
function TrendIcon({ trend }: { trend: string | null }) {
if (trend === "up") return <TrendingUp size={14} className="text-emerald-500" />;
if (trend === "down") return <TrendingDown size={14} className="text-rose-500" />;
return <Minus size={14} className="text-muted-foreground" />;
}
export default function EvaluationComparePage() {
const [templates, setTemplates] = useState<EvaluationTemplate[]>([]);
const [selectedTemplateIds, setSelectedTemplateIds] = useState<string[]>([]);
const [scores, setScores] = useState<Record<string, ScoreRecord[]>>({});
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<"table" | "radar">("table");
/** 加载模板列表。 */
const loadTemplates = useCallback(async () => {
setLoading(true);
try {
const resp = await listEvaluationTemplates({ is_default: true });
if (resp.data) {
setTemplates(resp.data);
// 默认选前 2 个
if (resp.data.length >= 2) {
setSelectedTemplateIds([resp.data[0].id, resp.data[1].id]);
} else if (resp.data.length === 1) {
setSelectedTemplateIds([resp.data[0].id]);
}
}
} catch {
// 静默处理
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadTemplates();
}, [loadTemplates]);
/** 加载选中模板的评分记录。 */
useEffect(() => {
async function loadScores() {
const newScores: Record<string, ScoreRecord[]> = {};
for (const tmplId of selectedTemplateIds) {
try {
const resp = await listScores({ template_id: tmplId, limit: 5 });
if (resp.data) newScores[tmplId] = resp.data;
} catch {
// 静默处理
}
}
setScores(newScores);
}
if (selectedTemplateIds.length > 0) {
loadScores();
}
}, [selectedTemplateIds]);
/** 切换模板选择。 */
function toggleTemplate(id: string) {
setSelectedTemplateIds((prev) => {
if (prev.includes(id)) return prev.filter((t) => t !== id);
if (prev.length >= 4) return prev;
return [...prev, id];
});
}
/** 获取模板最新评分。 */
function getLatestScore(tmplId: string): ScoreRecord | null {
const list = scores[tmplId];
if (!list || list.length === 0) return null;
return list[0];
}
/** 雷达图坐标计算。 */
function getRadarPoints(scoreRecord: ScoreRecord, dims: string[], centerX: number, centerY: number, radius: number): string {
return dims
.map((dim, i) => {
const scoreKey = DIMENSION_SCORE_KEYS[dim];
const value = (scoreRecord as unknown as Record<string, number | null>)[scoreKey];
const normalized = value ? value / 100 : 0;
const angle = (i / dims.length) * 2 * Math.PI - Math.PI / 2;
const x = centerX + radius * normalized * Math.cos(angle);
const y = centerY + radius * normalized * Math.sin(angle);
return `${x},${y}`;
})
.join(" ");
}
const selectedTemplates = templates.filter((t) => selectedTemplateIds.includes(t.id));
const radarSize = 320;
const radarCenter = radarSize / 2;
const radarRadius = radarSize / 2 - 40;
const radarColors = ["#6366f1", "#10b981", "#f59e0b", "#f43f5e"];
return (
<div className="space-y-6">
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitCompareArrows className="text-[var(--investor-primary)]" size={24} />
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-0.5 text-sm text-muted-foreground">
·
</p>
</div>
</div>
<div className="flex items-center gap-1 rounded-md border border-[var(--border)] p-1">
<button
type="button"
onClick={() => setViewMode("table")}
className={`rounded px-3 py-1 text-sm ${
viewMode === "table" ? "bg-[var(--investor-primary)] text-white" : "text-muted-foreground"
}`}
>
</button>
<button
type="button"
onClick={() => setViewMode("radar")}
className={`rounded px-3 py-1 text-sm ${
viewMode === "radar" ? "bg-[var(--investor-primary)] text-white" : "text-muted-foreground"
}`}
>
</button>
</div>
</div>
{/* 模板选择器 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-4">
<p className="text-xs text-muted-foreground"> 4 </p>
<div className="mt-2 flex flex-wrap gap-2">
{templates.map((tmpl) => (
<button
key={tmpl.id}
type="button"
onClick={() => toggleTemplate(tmpl.id)}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
selectedTemplateIds.includes(tmpl.id)
? "border-indigo-300 bg-indigo-50 text-indigo-600"
: "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"
}`}
>
{tmpl.name}
</button>
))}
{templates.length === 0 && !loading && (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
</div>
{loading && (
<div className="flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)}
{/* 对比内容 */}
{!loading && selectedTemplates.length > 0 && (
<>
{viewMode === "table" ? (
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<h2 className="text-sm font-medium text-gray-700"></h2>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)]">
<th className="py-2 text-left text-xs font-medium text-muted-foreground"></th>
{selectedTemplates.map((tmpl, idx) => {
const score = getLatestScore(tmpl.id);
return (
<th key={tmpl.id} className="px-4 py-2 text-center">
<div className="flex flex-col items-center">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: radarColors[idx] }}
/>
<span className="mt-1 text-xs font-medium">{tmpl.name}</span>
{score ? (
<span className={`mt-0.5 text-lg font-bold ${getScoreColor(score.total_score)}`}>
{score.total_score.toFixed(1)}
</span>
) : (
<span className="mt-0.5 text-xs text-muted-foreground"></span>
)}
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{ALL_DIMENSIONS.map((dim) => {
const isEnabled = selectedTemplates.some(
(t) => !t.disabled_dimensions.includes(dim),
);
if (!isEnabled) return null;
return (
<tr key={dim} className="border-b border-[var(--border)] last:border-0">
<td className="py-2 text-xs text-muted-foreground">
{DIMENSION_LABELS[dim]}
</td>
{selectedTemplates.map((tmpl) => {
const score = getLatestScore(tmpl.id);
const isDisabled = tmpl.disabled_dimensions.includes(dim);
const scoreKey = DIMENSION_SCORE_KEYS[dim];
const value = score
? (score as unknown as Record<string, number | null>)[scoreKey]
: null;
return (
<td key={tmpl.id} className="px-4 py-2 text-center">
{isDisabled ? (
<span className="text-xs text-muted-foreground line-through"></span>
) : value !== null && value !== undefined ? (
<div className="flex flex-col items-center">
<span className={`text-sm font-medium ${getScoreColor(value)}`}>
{value.toFixed(1)}
</span>
<div className="mt-1 h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
className={`h-full rounded-full ${getScoreBgColor(value)}`}
style={{ width: `${value}%` }}
/>
</div>
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{/* 模板信息对比 */}
<div className="mt-6 border-t border-[var(--border)] pt-4">
<h3 className="text-xs font-medium text-gray-600"></h3>
<div className="mt-2 grid grid-cols-2 gap-4 md:grid-cols-4">
{selectedTemplates.map((tmpl) => (
<div key={tmpl.id} className="rounded-md border border-[var(--border)] p-3">
<p className="text-xs font-medium">{tmpl.name}</p>
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground">
<p>: {FUND_TYPE_LABELS[tmpl.fund_type] ?? tmpl.fund_type}</p>
<p>: {STAGE_LABELS[tmpl.company_stage] ?? tmpl.company_stage}</p>
<p>: {INDUSTRY_LABELS[tmpl.industry] ?? tmpl.industry}</p>
<p>: {tmpl.enabled_dimensions.length} / 14</p>
</div>
</div>
))}
</div>
</div>
</div>
) : (
/* 雷达图视图 */
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<h2 className="text-sm font-medium text-gray-700"></h2>
{/* SVG 雷达图 */}
<div className="mt-4 flex flex-col items-center">
<svg width={radarSize} height={radarSize} className="max-w-full">
{/* 背景网格 */}
{[0.25, 0.5, 0.75, 1.0].map((ratio) => (
<circle
key={ratio}
cx={radarCenter}
cy={radarCenter}
r={radarRadius * ratio}
fill="none"
stroke="var(--border)"
strokeWidth={1}
opacity={0.5}
/>
))}
{/* 轴线 */}
{ALL_DIMENSIONS.map((dim, i) => {
const angle = (i / ALL_DIMENSIONS.length) * 2 * Math.PI - Math.PI / 2;
const x = radarCenter + radarRadius * Math.cos(angle);
const y = radarCenter + radarRadius * Math.sin(angle);
return (
<line
key={dim}
x1={radarCenter}
y1={radarCenter}
x2={x}
y2={y}
stroke="var(--border)"
strokeWidth={1}
opacity={0.3}
/>
);
})}
{/* 维度标签 */}
{ALL_DIMENSIONS.map((dim, i) => {
const angle = (i / ALL_DIMENSIONS.length) * 2 * Math.PI - Math.PI / 2;
const labelRadius = radarRadius + 20;
const x = radarCenter + labelRadius * Math.cos(angle);
const y = radarCenter + labelRadius * Math.sin(angle);
return (
<text
key={dim}
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
className="fill-muted-foreground text-[9px]"
>
{DIMENSION_LABELS[dim]}
</text>
);
})}
{/* 评分多边形 */}
{selectedTemplates.map((tmpl, idx) => {
const score = getLatestScore(tmpl.id);
if (!score) return null;
const enabledDims = tmpl.enabled_dimensions;
const points = getRadarPoints(
score,
enabledDims,
radarCenter,
radarCenter,
radarRadius,
);
return (
<polygon
key={tmpl.id}
points={points}
fill={radarColors[idx]}
fillOpacity={0.1}
stroke={radarColors[idx]}
strokeWidth={2}
/>
);
})}
</svg>
{/* 图例 */}
<div className="mt-4 flex flex-wrap justify-center gap-4">
{selectedTemplates.map((tmpl, idx) => {
const score = getLatestScore(tmpl.id);
return (
<div key={tmpl.id} className="flex items-center gap-2">
<span
className="h-3 w-3 rounded-sm"
style={{ backgroundColor: radarColors[idx] }}
/>
<span className="text-xs">{tmpl.name}</span>
{score && (
<span className={`text-xs font-medium ${getScoreColor(score.total_score)}`}>
{score.total_score.toFixed(1)}
</span>
)}
</div>
);
})}
</div>
</div>
{/* 评分历史趋势 */}
{Object.values(scores).some((s) => s.length > 1) && (
<div className="mt-6 border-t border-[var(--border)] pt-4">
<h3 className="text-xs font-medium text-gray-600"></h3>
<div className="mt-2 space-y-2">
{selectedTemplates.map((tmpl) => {
const tmplScores = scores[tmpl.id];
if (!tmplScores || tmplScores.length === 0) return null;
return (
<div key={tmpl.id} className="flex items-center gap-3">
<span className="w-32 truncate text-xs">{tmpl.name}</span>
<div className="flex flex-1 items-center gap-2">
{tmplScores.slice(0, 5).reverse().map((s, i) => (
<div key={s.id} className="flex items-center gap-1">
{i > 0 && <TrendIcon trend={s.trend} />}
<span className={`text-xs font-medium ${getScoreColor(s.total_score)}`}>
{s.total_score.toFixed(1)}
</span>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</>
)}
{!loading && selectedTemplates.length === 0 && templates.length > 0 && (
<div className="rounded-lg border border-dashed border-[var(--border)] p-12 text-center">
<GitCompareArrows size={32} className="mx-auto text-muted-foreground" />
<p className="mt-3 text-sm text-muted-foreground"> 1 </p>
</div>
)}
</div>
);
}
@@ -0,0 +1,413 @@
/** 评价模板配置页 — 6 轴参数选择 + 权重预览 + 模板管理。 */
"use client";
import { useState, useEffect, useCallback } from "react";
import { Settings2, Plus, ChevronDown, ChevronRight, Loader2, Save, BarChart3 } from "lucide-react";
import {
computeWeights,
listEvaluationTemplates,
createEvaluationTemplate,
type EvaluationTemplate,
} from "@/lib/api-v2";
import { DIMENSION_LABELS } from "@/lib/evaluation-constants";
/** 6 轴选项定义。 */
const AXIS_OPTIONS = {
fund_type: [
{ value: "angel", label: "天使/种子基金" },
{ value: "early_vc", label: "早期VC" },
{ value: "growth_vc", label: "成长期VC" },
{ value: "pe", label: "PE/并购基金" },
{ value: "cvc", label: "产业基金" },
{ value: "fof", label: "母基金" },
{ value: "distress", label: "困境/特殊机会" },
{ value: "esg", label: "ESG/影响力" },
],
fund_lifecycle: [
{ value: "investment", label: "投资期" },
{ value: "growth", label: "成长期" },
{ value: "exit_preparation", label: "退出准备期" },
{ value: "liquidation", label: "清算期" },
],
company_stage: [
{ value: "seed", label: "种子/天使" },
{ value: "a", label: "A轮" },
{ value: "b", label: "B轮" },
{ value: "c", label: "C轮+" },
{ value: "pre_ipo", label: "Pre-IPO" },
],
industry: [
{ value: "ai", label: "AI/SaaS" },
{ value: "saas", label: "企业服务" },
{ value: "hardware", label: "硬科技/芯片" },
{ value: "biotech", label: "生物医药" },
{ value: "consumer", label: "消费品牌" },
{ value: "fintech", label: "金融科技" },
{ value: "manufacturing", label: "新能源/先进制造" },
],
strategy: [
{ value: "growth", label: "成长型" },
{ value: "value", label: "价值型" },
{ value: "empowerment", label: "投后赋能型" },
{ value: "turnaround", label: "困境反转型" },
],
investor_type: [
{ value: "gp", label: "GP/合伙人" },
{ value: "post_invest_lead", label: "投后负责人" },
{ value: "investor", label: "投资经理" },
],
} as const;
/** 轴标签。 */
const AXIS_LABELS: Record<string, string> = {
fund_type: "基金类型",
fund_lifecycle: "存续期阶段",
company_stage: "企业阶段",
industry: "产业赛道",
strategy: "投资策略",
investor_type: "投资人类型",
};
export default function EvaluationTemplatesPage() {
const [params, setParams] = useState({
fund_type: "early_vc",
fund_lifecycle: "investment",
company_stage: "a",
industry: "ai",
strategy: "growth",
investor_type: "investor",
});
const [weights, setWeights] = useState<Record<string, number> | null>(null);
const [enabledDims, setEnabledDims] = useState<string[]>([]);
const [disabledDims, setDisabledDims] = useState<string[]>([]);
const [customMetrics, setCustomMetrics] = useState<Array<{ key: string; label: string; description: string }>>([]);
const [computing, setComputing] = useState(false);
const [showDisabled, setShowDisabled] = useState(false);
const [templates, setTemplates] = useState<EvaluationTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newTemplateName, setNewTemplateName] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
/** 实时计算权重。 */
const doCompute = useCallback(async () => {
setComputing(true);
setError("");
try {
const resp = await computeWeights(params);
const data = resp.data;
if (data) {
setWeights(data.weights);
setEnabledDims(data.enabled_dimensions);
setDisabledDims(data.disabled_dimensions);
setCustomMetrics(data.custom_metrics);
}
} catch (err) {
setError(err instanceof Error ? err.message : "权重计算失败");
} finally {
setComputing(false);
}
}, [params]);
useEffect(() => {
doCompute();
}, [doCompute]);
/** 加载模板列表。 */
const loadTemplates = useCallback(async () => {
setLoadingTemplates(true);
try {
const resp = await listEvaluationTemplates({
fund_type: params.fund_type,
company_stage: params.company_stage,
industry: params.industry,
});
if (resp.data) setTemplates(resp.data);
} catch {
// 静默处理
} finally {
setLoadingTemplates(false);
}
}, [params.fund_type, params.company_stage, params.industry]);
useEffect(() => {
loadTemplates();
}, [loadTemplates]);
/** 创建模板。 */
async function handleCreateTemplate() {
if (!newTemplateName.trim()) {
setError("请输入模板名称");
return;
}
setCreating(true);
setError("");
try {
await createEvaluationTemplate({
name: newTemplateName,
...params,
});
setShowCreateDialog(false);
setNewTemplateName("");
await loadTemplates();
} catch (err) {
setError(err instanceof Error ? err.message : "创建失败");
} finally {
setCreating(false);
}
}
const totalWeight = weights ? Object.values(weights).reduce((a, b) => a + b, 0) : 0;
return (
<div className="space-y-6">
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings2 className="text-[var(--investor-primary)]" size={24} />
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="mt-0.5 text-sm text-muted-foreground">
6 · 50 ·
</p>
</div>
</div>
<button
type="button"
onClick={() => setShowCreateDialog(true)}
className="flex items-center gap-1.5 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white hover:opacity-90"
>
<Plus size={16} />
</button>
</div>
{/* 6 轴参数选择器 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<h2 className="text-sm font-medium text-gray-700">6 </h2>
<div className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
{Object.entries(AXIS_OPTIONS).map(([key, options]) => (
<div key={key}>
<label className="text-xs text-muted-foreground">{AXIS_LABELS[key]}</label>
<select
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none"
value={params[key as keyof typeof params]}
onChange={(e) =>
setParams((prev) => ({ ...prev, [key]: e.target.value }))
}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
))}
</div>
</div>
{/* 权重预览 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<BarChart3 size={18} className="text-[var(--investor-primary)]" />
<h2 className="text-sm font-medium text-gray-700"></h2>
{computing && <Loader2 size={14} className="animate-spin text-muted-foreground" />}
</div>
<span className="text-xs text-muted-foreground">
: <span className="font-medium text-gray-700">{totalWeight.toFixed(2)}%</span>
</span>
</div>
{error && (
<div className="mt-3 rounded-md border border-rose-200 bg-rose-50 p-2 text-xs text-rose-600">
{error}
</div>
)}
{/* 权重条形图 */}
<div className="mt-4 space-y-2">
{weights &&
Object.entries(weights)
.sort(([, a], [, b]) => b - a)
.map(([dim, weight]) => (
<div key={dim} className="flex items-center gap-3">
<span className="w-24 text-xs text-muted-foreground">
{DIMENSION_LABELS[dim] ?? dim}
</span>
<div className="flex-1">
<div className="h-5 overflow-hidden rounded bg-muted">
<div
className="flex h-full items-center justify-end rounded bg-indigo-500 px-1 text-[10px] text-white transition-all"
style={{ width: `${Math.min(weight * 3, 100)}%` }}
>
{weight > 3 && `${weight.toFixed(1)}%`}
</div>
</div>
</div>
<span className="w-12 text-right text-xs font-medium">
{weight.toFixed(2)}%
</span>
</div>
))}
</div>
{/* 禁用维度 */}
{disabledDims.length > 0 && (
<div className="mt-4">
<button
type="button"
onClick={() => setShowDisabled(!showDisabled)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-gray-700"
>
{showDisabled ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
({disabledDims.length})
</button>
{showDisabled && (
<div className="mt-2 flex flex-wrap gap-2">
{disabledDims.map((dim) => (
<span
key={dim}
className="rounded bg-muted px-2 py-1 text-xs text-muted-foreground line-through"
>
{DIMENSION_LABELS[dim] ?? dim}
</span>
))}
</div>
)}
</div>
)}
{/* 专属指标 */}
{customMetrics.length > 0 && (
<div className="mt-4 border-t border-[var(--border)] pt-3">
<h3 className="text-xs font-medium text-gray-600"></h3>
<div className="mt-2 grid grid-cols-2 gap-2 md:grid-cols-4">
{customMetrics.map((metric) => (
<div
key={metric.key}
className="rounded-md border border-[var(--border)] p-2"
>
<p className="text-xs font-medium">{metric.label}</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{metric.description}</p>
</div>
))}
</div>
</div>
)}
</div>
{/* 模板列表 */}
<div className="rounded-lg border border-[var(--border)] bg-white p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-gray-700">
({templates.length})
</h2>
{loadingTemplates && <Loader2 size={14} className="animate-spin text-muted-foreground" />}
</div>
{templates.length === 0 && !loadingTemplates ? (
<div className="mt-4 rounded-md border border-dashed border-[var(--border)] p-6 text-center">
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1 text-xs text-muted-foreground">"新建模板"</p>
</div>
) : (
<div className="mt-4 space-y-2">
{templates.map((tmpl) => (
<div
key={tmpl.id}
className="flex items-center justify-between rounded-md border border-[var(--border)] p-3 hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<div>
<p className="text-sm font-medium">{tmpl.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{AXIS_OPTIONS.fund_type.find((o) => o.value === tmpl.fund_type)?.label} ·{" "}
{AXIS_OPTIONS.company_stage.find((o) => o.value === tmpl.company_stage)?.label} ·{" "}
{AXIS_OPTIONS.industry.find((o) => o.value === tmpl.industry)?.label}
</p>
</div>
{tmpl.is_default && (
<span className="rounded bg-indigo-50 px-2 py-0.5 text-xs text-indigo-600">
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{Object.keys(tmpl.weights).length}
</span>
<span className="text-xs text-muted-foreground">v{tmpl.version}</span>
</div>
</div>
))}
</div>
)}
</div>
{/* 创建模板对话框 */}
{showCreateDialog && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={() => setShowCreateDialog(false)}
>
<div
className="w-96 rounded-lg bg-white p-5 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-medium"></h3>
<p className="mt-1 text-xs text-muted-foreground">
6
</p>
<div className="mt-3">
<label className="text-xs text-muted-foreground"></label>
<input
type="text"
className="mt-1 w-full rounded-md border border-[var(--border)] px-3 py-2 text-sm"
placeholder="如:早期VC-AI-成长型"
value={newTemplateName}
onChange={(e) => setNewTemplateName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateTemplate();
}}
/>
</div>
<div className="mt-3 rounded-md bg-muted p-2 text-xs text-muted-foreground">
: {AXIS_OPTIONS.fund_type.find((o) => o.value === params.fund_type)?.label}
{" · "}
: {AXIS_OPTIONS.company_stage.find((o) => o.value === params.company_stage)?.label}
{" · "}
: {AXIS_OPTIONS.industry.find((o) => o.value === params.industry)?.label}
</div>
{error && (
<p className="mt-2 text-xs text-rose-600">{error}</p>
)}
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => setShowCreateDialog(false)}
className="rounded-md border border-[var(--border)] px-4 py-2 text-sm hover:bg-muted"
>
</button>
<button
type="button"
onClick={handleCreateTemplate}
disabled={creating}
className="flex items-center gap-1.5 rounded-md bg-[var(--investor-primary)] px-4 py-2 text-sm text-white hover:opacity-90 disabled:opacity-50"
>
{creating ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+191
View File
@@ -531,3 +531,194 @@ export async function founderOverview() {
export async function founderHealth() {
return apiFetch(`/founder/health`);
}
// ============ 评价指标体系 ============
/** 权重计算请求参数。 */
export interface WeightComputeParams {
fund_type: string;
fund_lifecycle?: string;
company_stage?: string;
industry?: string;
strategy?: string;
investor_type?: string;
}
/** 评价模板。 */
export interface EvaluationTemplate {
id: string;
name: string;
fund_type: string;
fund_lifecycle: string;
company_stage: string;
industry: string;
strategy: string;
investor_type: string;
weights: Record<string, number>;
enabled_dimensions: string[];
disabled_dimensions: string[];
custom_metrics: { metrics: Array<{ key: string; label: string; description: string }> } | null;
is_default: boolean;
version: number;
}
/** 评分记录。 */
export interface ScoreRecord {
id: string;
company_id: string;
total_score: number;
financial_score: number | null;
operational_score: number | null;
ai_commercial_score: number | null;
ai_cost_score: number | null;
org_talent_score: number | null;
product_tech_score: number | null;
market_compete_score: number | null;
governance_score: number | null;
financing_score: number | null;
synergy_score: number | null;
ai_model_product_score: number | null;
data_compliance_score: number | null;
team_tech_score: number | null;
customer_success_score: number | null;
trend: string | null;
template_id: string | null;
fund_type: string | null;
fund_lifecycle: string | null;
company_stage: string | null;
industry: string | null;
strategy: string | null;
calculated_at: string | null;
}
/** 基金信息。 */
export interface FundInfo {
id: string;
name: string;
fund_type: string;
strategy: string;
established_date: string | null;
total_lifespan_months: number;
investment_period_months: number;
current_lifecycle: string;
lp_composition: Record<string, number> | null;
primary_market: string | null;
}
/** 计算权重(不持久化)。 */
export async function computeWeights(params: WeightComputeParams) {
return apiFetch<{
weights: Record<string, number>;
enabled_dimensions: string[];
disabled_dimensions: string[];
custom_metrics: Array<{ key: string; label: string; description: string }>;
}>("/evaluation/weights/compute", {
method: "POST",
body: JSON.stringify({
fund_type: params.fund_type,
fund_lifecycle: params.fund_lifecycle ?? "investment",
company_stage: params.company_stage ?? "a",
industry: params.industry ?? "ai",
strategy: params.strategy ?? "growth",
investor_type: params.investor_type ?? "investor",
}),
});
}
/** 获取模板列表。 */
export async function listEvaluationTemplates(params?: {
fund_type?: string;
fund_lifecycle?: string;
company_stage?: string;
industry?: string;
strategy?: string;
is_default?: boolean;
}) {
const query = new URLSearchParams();
if (params?.fund_type) query.set("fund_type", params.fund_type);
if (params?.fund_lifecycle) query.set("fund_lifecycle", params.fund_lifecycle);
if (params?.company_stage) query.set("company_stage", params.company_stage);
if (params?.industry) query.set("industry", params.industry);
if (params?.strategy) query.set("strategy", params.strategy);
if (params?.is_default !== undefined) query.set("is_default", String(params.is_default));
return apiFetch<EvaluationTemplate[]>(`/evaluation/templates?${query.toString()}`);
}
/** 获取模板详情。 */
export async function getEvaluationTemplate(id: string) {
return apiFetch<EvaluationTemplate>(`/evaluation/templates/${id}`);
}
/** 创建模板。 */
export async function createEvaluationTemplate(data: {
name: string;
fund_type: string;
fund_lifecycle?: string;
company_stage?: string;
industry?: string;
strategy?: string;
investor_type?: string;
weights_json?: Record<string, number>;
is_default?: boolean;
}) {
return apiFetch<{ id: string; name: string; weights: Record<string, number> }>(
"/evaluation/templates",
{ method: "POST", body: JSON.stringify(data) },
);
}
/** 计算评分。 */
export async function calculateScore(data: {
company_id: string;
template_id?: string;
fund_type?: string;
fund_lifecycle?: string;
company_stage?: string;
industry?: string;
strategy?: string;
structured_data?: Record<string, unknown>;
}) {
return apiFetch<{
score_id?: string;
total_score: number;
dimension_scores: Record<string, number>;
template: { id: string; name: string; weights: Record<string, number> } | null;
}>("/evaluation/score", {
method: "POST",
body: JSON.stringify(data),
});
}
/** 获取评分历史。 */
export async function listScores(params?: {
company_id?: string;
template_id?: string;
limit?: number;
}) {
const query = new URLSearchParams();
if (params?.company_id) query.set("company_id", params.company_id);
if (params?.template_id) query.set("template_id", params.template_id);
if (params?.limit) query.set("limit", String(params.limit));
return apiFetch<ScoreRecord[]>(`/evaluation/scores?${query.toString()}`);
}
/** 获取基金列表(评价体系)。 */
export async function listEvaluationFunds() {
return apiFetch<FundInfo[]>("/evaluation/funds");
}
/** 创建基金(评价体系)。 */
export async function createEvaluationFund(data: {
name: string;
fund_type: string;
strategy?: string;
established_date?: string;
total_lifespan_months?: number;
investment_period_months?: number;
primary_market?: string;
}) {
return apiFetch<{ id: string; name: string; current_lifecycle: string }>(
"/evaluation/funds",
{ method: "POST", body: JSON.stringify(data) },
);
}
+101
View File
@@ -0,0 +1,101 @@
/** 评价体系共享常量 — 维度标签、轴标签等。 */
/** 14 维度中文标签。 */
export const DIMENSION_LABELS: Record<string, string> = {
financial: "财务",
operational: "经营",
ai_commercial: "AI商业化",
ai_cost: "AI成本",
org_talent: "组织人才",
product_tech: "产品技术",
market_compete: "市场竞争",
governance: "治理合规",
financing: "融资资本",
synergy: "协同赋能",
ai_model_product: "AI模型产品",
data_compliance: "数据合规",
team_tech: "团队技术",
customer_success: "客户成功",
};
/** 维度 key → 评分字段 key 映射。 */
export const DIMENSION_SCORE_KEYS: Record<string, string> = {
financial: "financial_score",
operational: "operational_score",
ai_commercial: "ai_commercial_score",
ai_cost: "ai_cost_score",
org_talent: "org_talent_score",
product_tech: "product_tech_score",
market_compete: "market_compete_score",
governance: "governance_score",
financing: "financing_score",
synergy: "synergy_score",
ai_model_product: "ai_model_product_score",
data_compliance: "data_compliance_score",
team_tech: "team_tech_score",
customer_success: "customer_success_score",
};
/** 基金类型标签。 */
export const FUND_TYPE_LABELS: Record<string, string> = {
angel: "天使/种子基金",
early_vc: "早期VC",
growth_vc: "成长期VC",
pe: "PE/并购基金",
cvc: "产业基金",
fof: "母基金",
distress: "困境/特殊机会",
esg: "ESG/影响力",
};
/** 存续期标签。 */
export const LIFECYCLE_LABELS: Record<string, string> = {
investment: "投资期",
growth: "成长期",
exit_preparation: "退出准备期",
liquidation: "清算期",
};
/** 企业阶段标签。 */
export const STAGE_LABELS: Record<string, string> = {
seed: "种子/天使",
a: "A轮",
b: "B轮",
c: "C轮+",
pre_ipo: "Pre-IPO",
};
/** 产业赛道标签。 */
export const INDUSTRY_LABELS: Record<string, string> = {
ai: "AI/SaaS",
saas: "企业服务",
hardware: "硬科技/芯片",
biotech: "生物医药",
consumer: "消费品牌",
fintech: "金融科技",
manufacturing: "新能源/先进制造",
};
/** 投资策略标签。 */
export const STRATEGY_LABELS: Record<string, string> = {
growth: "成长型",
value: "价值型",
empowerment: "投后赋能型",
turnaround: "困境反转型",
};
/** 评分颜色等级。 */
export function getScoreColor(score: number): string {
if (score >= 80) return "text-emerald-600";
if (score >= 60) return "text-indigo-600";
if (score >= 40) return "text-amber-600";
return "text-rose-600";
}
/** 评分背景颜色。 */
export function getScoreBgColor(score: number): string {
if (score >= 80) return "bg-emerald-500";
if (score >= 60) return "bg-indigo-500";
if (score >= 40) return "bg-amber-500";
return "bg-rose-500";
}
+3 -1
View File
@@ -5,7 +5,7 @@ import {
AlertTriangle, FileText, Receipt, ScrollText, Users, Flag, ListTodo,
Network, UserCircle, TrendingUp, Target, GitBranch, Lightbulb,
Radar, Crosshair, TrendingUp as TrendUp, LogOut, Bot, Cpu, Network as Net,
BookOpen, ShieldCheck, FileBarChart, Settings,
BookOpen, ShieldCheck, FileBarChart, Settings, Settings2,
type LucideIcon,
} from "lucide-react";
@@ -99,6 +99,8 @@ export const NAV_DOMAINS: Record<string, NavDomain> = {
{ href: "/aars", label: "AAR 复盘", icon: BookOpen },
{ href: "/pre-mortems", label: "Pre-mortem", icon: ShieldCheck },
{ href: "/reports/templates", label: "投后报告", icon: FileBarChart },
{ href: "/evaluation", label: "评价模板", icon: Settings2 },
{ href: "/evaluation/compare", label: "评分对比", icon: GitCompareArrows },
],
},
};