feat(frontend): Phase 6 evaluation template config page + score compare view
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user