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
+11 -2
View File
@@ -136,6 +136,7 @@ async def seed_evaluation_templates(db_session) -> None:
""" """
from sqlalchemy import select from sqlalchemy import select
from app.models.evaluation_template import EvaluationTemplate from app.models.evaluation_template import EvaluationTemplate
from app.models.tenant import Tenant
# 检查是否已有数据 # 检查是否已有数据
result = await db_session.execute(select(EvaluationTemplate).limit(1)) result = await db_session.execute(select(EvaluationTemplate).limit(1))
@@ -145,10 +146,18 @@ async def seed_evaluation_templates(db_session) -> None:
logger.info("评价模板表已有数据,跳过种子初始化") logger.info("评价模板表已有数据,跳过种子初始化")
return return
# 获取第一个租户作为默认租户
tenant_result = await db_session.execute(select(Tenant).limit(1))
tenant = tenant_result.scalar_one_or_none()
if not tenant:
logger.warning("无租户数据,跳过评价模板种子初始化")
return
tenant_id = str(tenant.id)
templates = generate_preset_templates() templates = generate_preset_templates()
for tmpl_data in templates: for tmpl_data in templates:
tmpl = EvaluationTemplate(**tmpl_data) tmpl = EvaluationTemplate(tenant_id=tenant_id, **tmpl_data)
db_session.add(tmpl) db_session.add(tmpl)
await db_session.flush() await db_session.flush()
logger.info("预设评价模板已写入数据库: %d", len(templates)) logger.info("预设评价模板已写入数据库: %d (tenant=%s)", len(templates), tenant_id)
@@ -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() { export async function founderHealth() {
return apiFetch(`/founder/health`); 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, AlertTriangle, FileText, Receipt, ScrollText, Users, Flag, ListTodo,
Network, UserCircle, TrendingUp, Target, GitBranch, Lightbulb, Network, UserCircle, TrendingUp, Target, GitBranch, Lightbulb,
Radar, Crosshair, TrendingUp as TrendUp, LogOut, Bot, Cpu, Network as Net, Radar, Crosshair, TrendingUp as TrendUp, LogOut, Bot, Cpu, Network as Net,
BookOpen, ShieldCheck, FileBarChart, Settings, BookOpen, ShieldCheck, FileBarChart, Settings, Settings2,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
@@ -99,6 +99,8 @@ export const NAV_DOMAINS: Record<string, NavDomain> = {
{ href: "/aars", label: "AAR 复盘", icon: BookOpen }, { href: "/aars", label: "AAR 复盘", icon: BookOpen },
{ href: "/pre-mortems", label: "Pre-mortem", icon: ShieldCheck }, { href: "/pre-mortems", label: "Pre-mortem", icon: ShieldCheck },
{ href: "/reports/templates", label: "投后报告", icon: FileBarChart }, { href: "/reports/templates", label: "投后报告", icon: FileBarChart },
{ href: "/evaluation", label: "评价模板", icon: Settings2 },
{ href: "/evaluation/compare", label: "评分对比", icon: GitCompareArrows },
], ],
}, },
}; };