Files
qlib/frontend/web/app/experiments/[id]/page.tsx
T
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

610 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 归档详情页(`/experiments/{id}`)—— 「往复查看」的落点。
*
* 为什么单独一页,而不复用回测页:
* 回测页是**可编辑参数**的工作台,从它看归档会被误认为「当前参数就是归档参数」;
* 归档需要的是一个**只读、口径固定、可分享**的视图:URL 即快照地址,刷新/换设备都能看,
* 且必须明确回答两个问题 ——
* ① **选股条件**:这次回测到底按什么规则选股(因子/权重/条件/股票池/两级截断/周期/补位)
* ② **交易执行依据**:成交时点、价格口径、成本、涨跌停与停牌怎么处理、期末是否平仓
* 这两块来自归档里存的 **spec(复现依据)**,不来自当前页面状态,因此永不漂移;
* 同时用后端 `describe_strategy`(与引擎实执行规则同源)生成完整说明与计算公式。
*
* 结果区交给 `ArchiveResultView` 按归档 `kind` 分发(backtest/factor_test/selection 结构不同,
* 不能假定回测字段)——回测归档与刚跑完时看到的**完全同一套图表与表格**,
* 避免「归档里少一张图」的隐性不一致。
*
* **这里是 Server Component**:归档是只读内容,选股条件/执行依据/元数据必须在
* 服务端直出(否则不带 JS 的抓取与链接预览拿到的只是一个 Loading 骨架——
* 实测 SSR HTML 里搜不到「选股条件」)。图表与删除/导出等交互由客户端子组件承担。
*/
import { notFound } from "next/navigation";
import Link from "next/link";
import { ArchiveActions } from "@/components/ArchiveActions";
import { StrategyDocCard } from "@/components/StrategyDocCard";
import { ArchiveResultView } from "@/components/ArchiveResultView";
import { archivedFieldSummary } from "@/lib/archive";
import { PageHeader, Card, Pill, Empty, Banner } from "@/components/ui";
import { adjustLabel, experimentKindLabel, rebalanceLabel } from "@/lib/labels";
import { RichText } from "@/components/RichText";
import type { ExperimentDetail, FactorMeta, ResearchCondition, ResearchSpec, StrategyDoc } from "@/lib/types";
export const dynamic = "force-dynamic"; // 归档可能被删除/新增,禁止静态化缓存
/** 服务端访问后端:与 next.config.ts 的代理同一默认地址(服务端没有「同源」可用) */
const BACKEND = (process.env.BACKEND_API_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "");
async function serverGet<T>(path: string): Promise<T | null> {
try {
const r = await fetch(`${BACKEND}/api${path}`, { cache: "no-store" });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
async function serverPost<T>(path: string, body: unknown): Promise<T | null> {
try {
const r = await fetch(`${BACKEND}/api${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
});
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
export default async function ExperimentArchivePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: rawId } = await params;
const id = decodeURIComponent(rawId ?? "");
const [detail, factors] = await Promise.all([
serverGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`),
serverGet<FactorMeta[]>("/factors"),
]);
// 归档不存在 → 交给 Next 的 404(比渲染一个空壳页更诚实)
if (!detail) notFound();
const spec = (detail.spec ?? null) as Partial<ResearchSpec> | null;
const result = detail.result;
// 关键字段计数按 kind 给不同口径(用回测字段去数因子/选股结果是错的)
const fieldSummary = archivedFieldSummary(detail.kind, result);
// 与引擎执行规则同源的说明(后端按归档 spec 推导;推导失败时如实显示错误)
// 只有回测归档才需要「完整说明与计算公式」:describe 推导的是回测策略口径,
// 因子测试/选股用同一 spec 结构但规则并未执行,拉回来展示等于误导(且白花一次请求)。
const doc = spec && detail.kind === "backtest"
? await serverPost<StrategyDoc & { doc?: StrategyDoc }>("/strategies/describe", spec)
: null;
const docBody = doc ? ((doc as { doc?: StrategyDoc }).doc ?? doc) : null;
return (
<>
<PageHeader
title={`归档 ${detail.id}`}
sub={`${experimentKindLabel(detail.kind)} · ${detail.summary_text ?? "无摘要"}`}
/>
<ArchiveActions id={detail.id} spec={detail.spec} />
<ArchivedSpecCards detail={detail} spec={spec} factors={factors ?? []} />
{detail.kind === "backtest" ? (
<StrategyDocCard
doc={docBody}
loading={false}
error={spec && !docBody ? "后端未能给出该 spec 的说明(describe 接口失败)" : ""}
title="完整说明与计算公式(后端按归档 spec 推导,与引擎执行规则同源)"
/>
) : null}
<Card icon="archive" title="归档元数据(复现依据)">
<div className="kv-grid">
<KV label="实验 ID" value={detail.id} mono />
<KV label="类型" value={experimentKindLabel(detail.kind)} />
<KV label="归档时间" value={fmtTime(detail.created_at)} mono />
<KV label="代码版本" value={detail.code_version ?? "—"} mono />
<KV
label="数据版本"
value={detail.data_version ?? "未记录(该归档早于数据指纹上线)"}
mono
warn={!detail.data_version}
/>
<KV label="来源作业" value={detail.job_id ?? "同步接口调用(无作业 id)"} mono />
<KV
label="归档体积"
value={detail.result_bytes ? `${(detail.result_bytes / 1024).toFixed(0)} KB` : "—"}
/>
<KV
label="区间"
value={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"}
mono
/>
</div>
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="数据版本由后端在归档时写入(数据快照指纹):**同样的代码版本 + 同样的数据版本 + 同样的 spec,才应当复现出同样的结果**。若数据版本显示「未记录」,说明该归档产生于指纹上线之前 —— 此时不要把它当作可严格复现的依据。" />
</div>
<div className="row" style={{ marginTop: 10, gap: 8 }}>
{fieldSummary ? <span className="hint">{fieldSummary}</span> : null}
</div>
</Card>
{result ? (
<ArchiveResultView
kind={detail.kind}
result={result}
archive={{
id: detail.id,
created_at: detail.created_at,
code_version: detail.code_version,
data_version: detail.data_version,
job_id: detail.job_id,
}}
/>
) : (
<Card>
<Empty
icon="archive"
title="该归档没有可展示的结果"
hint={
detail.kind === "factor_test"
? "因子测试归档没有净值曲线(结果结构不同);结果为空时只能导出 JSON 查看原始内容。"
: "结果为空(可能是历史版本归档)。可用上方「导出完整 JSON」查看原始内容。"
}
/>
</Card>
)}
</>
);
}
/* ------------------------------------------------------------------ */
function KV({
label,
value,
mono,
warn,
}: {
label: string;
value: string;
mono?: boolean;
warn?: boolean;
}) {
return (
<div className="kv">
<span className="kv__k">{label}</span>
<span className={`kv__v${mono ? " mono" : ""}${warn ? " kv__v--warn" : ""}`}>{value}</span>
</div>
);
}
function fmtTime(v?: string | null): string {
if (!v) return "—";
const d = new Date(v);
if (Number.isNaN(d.getTime())) return v;
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function directionText(name: string, factors: FactorMeta[]): string {
const f = factors.find((x) => x.name === name);
if (!f) return "—";
return f.direction === "lower_is_better" ? "越低越好" : "越高越好";
}
function opText(op?: string): string {
switch (op) {
case "gt":
return ">";
case "gte":
return "≥";
case "lt":
return "<";
case "lte":
return "≤";
case "eq":
return "=";
case "ne":
return "≠";
case "in":
return "∈";
case "not_in":
return "∉";
default:
return op ?? "?";
}
}
function condText(c: ResearchCondition): string {
const right = c.ref ? `(字段 ${c.ref})` : `${c.value ?? ""}`;
return `${c.field} ${opText(c.op)} ${right}`;
}
/**
* 因子测试归档的 spec 卡:只展示**真正生效**的字段。
*
* 诚实性要点(AGENT §7/§24):因子测试按横截面算 IC / RankIC / 分层收益,
* **不做两级截断、不下单、不计成本**。但归档 spec 沿用了与回测相同的请求结构
* (含 `selection` / `costs` / `initial_capital`)—— 把这些字段按回测口径展示,
* 会让人以为这次跑过调仓与撮合。所以这里只列生效项,并显式说明哪些字段未生效。
*/
function FactorTestSpecCards({
spec,
factors,
}: {
spec: Partial<ResearchSpec>;
factors: FactorMeta[];
}) {
const uni = (spec.universe ?? {}) as Record<string, unknown>;
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
const period = spec.period ?? [];
return (
<>
<Card
icon="flask"
title="因子测试配置(本次实际生效的字段)"
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
>
<div className="spec-grid">
<SpecBlock title="被测因子(横截面 z-score 加权)">
{factorList.length === 0 ? (
<SpecRow k="因子" v="—" />
) : (
factorList.map((f) => (
<SpecRow
key={f.name}
k={f.name}
v={`权重 ${f.weight} ${directionText(f.name, factors)}`}
/>
))
)}
</SpecBlock>
<SpecBlock title="横截面范围(universe)">
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
<SpecRow k="剔除 ST" v={uni.exclude_st ? "是(按当时名称判定)" : "否"} />
<SpecRow
k="剔除停牌"
v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"}
/>
<SpecRow
k="上市天数下限"
v={uni.min_listing_days ? `${String(uni.min_listing_days)} 天` : "无限制"}
/>
</SpecBlock>
<SpecBlock title="测试区间">
<SpecRow
k="区间"
v={period.length === 2 ? `${period[0]} ~ ${period[1]}` : "—"}
mono
/>
</SpecBlock>
</div>
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="本次归档类型是**因子测试**:按横截面计算 IC / RankIC / 分层收益,**不做两级截断(n→x)、不下单、不计交易成本**。归档 spec 里沿用了与回测相同的请求结构(`selection` / `costs` / `initial_capital` 等),但这些字段本次**未生效** —— 不要把它们当作执行依据。" />
</div>
</Card>
</>
);
}
/**
* 选股归档的 spec 卡:一次性选股的规则(无调仓、无成交)。
*/
function SelectionSpecCards({
spec,
factors,
}: {
spec: Partial<ResearchSpec>;
factors: FactorMeta[];
}) {
const uni = (spec.universe ?? {}) as Record<string, unknown>;
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
const conds = ((spec as Record<string, unknown>).conditions ?? []) as ResearchCondition[];
const method = String((spec as Record<string, unknown>).method ?? "score");
const topN = (spec as Record<string, unknown>).top_n;
const topPct = (spec as Record<string, unknown>).top_pct;
return (
<Card
icon="target"
title="选股条件(本次一次性选股实际使用的规则)"
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
>
<div className="spec-grid">
<SpecBlock title="时点与方式">
<SpecRow
k="选股时点"
v={String((spec as Record<string, unknown>).as_of ?? "最近交易日")}
mono
/>
<SpecRow k="方式" v={method === "condition" ? "条件筛选" : "因子评分"} />
<SpecRow
k="复权口径"
v={spec.price_adjustment === "hfq" ? "后复权(hfq)" : "不复权(none)"}
/>
</SpecBlock>
<SpecBlock title={method === "condition" ? "筛选条件(AND)" : "打分因子(横截面 z-score 加权)"}>
{method === "condition"
? conds.length === 0
? <SpecRow k="条件" v="—" />
: conds.map((c) => <SpecRow key={condText(c)} k={c.field} v={condText(c)} />)
: factorList.length === 0
? <SpecRow k="因子" v="—" />
: factorList.map((f) => (
<SpecRow
key={f.name}
k={f.name}
v={`权重 ${f.weight} ${directionText(f.name, factors)}`}
/>
))}
</SpecBlock>
<SpecBlock title="股票池与选出数量">
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
<SpecRow k="剔除 ST" v={uni.exclude_st ? "是(按当时名称判定)" : "否"} />
<SpecRow
k="剔除停牌"
v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"}
/>
<SpecRow
k="选出数量"
v={
topN
? `Top ${String(topN)}`
: topPct
? `前 ${String(topPct)}%`
: "—"
}
/>
</SpecBlock>
</div>
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="本次归档类型是**一次性选股**:只在上述**单个交易日**求值一次,**没有调仓周期、没有成交与成本**(不会买卖任何东西)。归档 spec 里若出现 `costs` / `rebalance`,那只是同一请求结构的字段,本次未生效。" />
</div>
</Card>
);
}
/** 选股条件 + 交易执行依据:直接用归档 spec 渲染,回答「这次回测到底怎么选的、怎么成交的」 */
function ArchivedSpecCards({
detail,
spec,
factors,
}: {
detail: ExperimentDetail;
spec: Partial<ResearchSpec> | null;
factors: FactorMeta[];
}) {
if (!spec) {
return (
<Card icon="book" title="选股条件与交易执行依据">
<div className="hint">
该归档没有存储 spec(早于 spec 归档上线),无法回溯当时的规则;只能看下方结果。
</div>
</Card>
);
}
// 按归档类型分发:**只有回测**才有「选股条件 + 交易执行依据」这一对。
// 因子测试/选股与回测共用同一请求结构(spec 里也有 selection/costs/initial_capital),
// 但那些字段在因子测试/选股里**从未生效** —— 沿用回测口径展示等于谎报执行依据。
if (detail.kind === "factor_test") {
return <FactorTestSpecCards spec={spec} factors={factors} />;
}
if (detail.kind === "selection") {
return <SelectionSpecCards spec={spec} factors={factors} />;
}
if (detail.kind !== "backtest") {
return (
<Card icon="book" title={`归档 spec(类型:${detail.kind})`}>
<div className="hint">
<RichText text="该类型的 spec 展示口径尚未定义,这里**不猜测**它的含义;原始 spec 可用上方「查看原始 spec(JSON)」逐字查看。" />
</div>
</Card>
);
}
const uni = (spec.universe ?? {}) as Record<string, unknown>;
const sel = (spec.selection ?? {}) as Record<string, unknown>;
const costs = (spec.costs ?? {}) as Record<string, unknown>;
const conds = (spec.conditions ?? []) as ResearchCondition[];
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
const fillPolicy =
sel.defer_buy === true
? "顺延买入(等它到之后首个不涨停的交易日按收盘价买)"
: sel.allow_substitute === false
? "不补位(买不进就空着,实际持仓可能少于目标数)"
: "替补买入(从候选池之外按复合分往下找可买标的)";
return (
<>
<Card
icon="target"
title="选股条件(本次回测实际使用的规则)"
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
>
<div className="spec-grid">
<SpecBlock title="股票池(universe)">
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
<SpecRow
k="剔除 ST"
v={uni.exclude_st ? "是(按择股日**当时名称**判定)" : "否"}
/>
<SpecRow
k="上市天数下限"
v={uni.min_listing_days ? `${uni.min_listing_days} 个自然日` : "无限制"}
/>
<SpecRow k="指数成分" v={uni.index_code ? String(uni.index_code) : "不限定"} />
<SpecRow
k="白名单"
v={
Array.isArray(uni.symbols) && uni.symbols.length
? `${uni.symbols.length} 只`
: "全市场"
}
/>
<SpecRow k="剔除停牌" v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"} />
</SpecBlock>
<SpecBlock title="打分因子(横截面 z-score 加权)">
{factorList.length === 0 ? (
<div className="hint">无(纯条件选股)</div>
) : (
factorList.map((f) => (
<SpecRow
key={f.name}
k={`${f.name} 权重 ${f.weight}`}
v={directionText(f.name, factors)}
mono
/>
))
)}
<div className="hint" style={{ marginTop: 6 }}>
复合分 = Σ(权重 × 方向 × 当日截面 z-score),降序取候选池。
</div>
</SpecBlock>
<SpecBlock title="过滤条件(AND,逐择股日求值)">
{conds.length === 0 ? (
<div className="hint">无附加条件</div>
) : (
conds.map((c, i) => <SpecRow key={`${c.field}-${i}`} k={`条件 ${i + 1}`} v={condText(c)} mono />)
)}
</SpecBlock>
<SpecBlock title="两级截断与周期">
<SpecRow k="候选池 n" v={sel.top_n != null ? `${sel.top_n} 只` : "—"} />
<SpecRow
k="实际持仓 x"
v={
sel.hold_top_x != null && sel.hold_top_x !== sel.top_n
? `${sel.hold_top_x} 只`
: `= n${sel.top_n != null ? `(${sel.top_n} 只)` : ""}`
}
/>
<SpecRow
k="择股周期 m"
v={
spec.selection_interval_months
? `每 ${spec.selection_interval_months} 个月重新择股`
: "每次调仓都重新择股"
}
/>
<SpecRow
k="调仓周期 y"
v={
spec.rebalance_interval_months
? `每 ${spec.rebalance_interval_months} 个月调仓`
: `跟随调仓频率(${rebalanceLabel(spec.rebalance)})`
}
/>
<SpecRow k="买不进时" v={fillPolicy} />
<SpecRow k="资金分配" v="等权(可用现金 / 目标持仓数)" />
</SpecBlock>
</div>
</Card>
<Card icon="scale" title="交易执行依据(撮合价 / 成本 / 约束)">
<div className="spec-grid">
<SpecBlock title="成交口径">
<SpecRow k="价格口径" v={adjustLabel(spec.price_adjustment as string)} />
<SpecRow k="成交时点" v="调仓日**收盘**(调仓在当日收盘生效,自次日起计收益)" />
<SpecRow
k="买入价"
v={`收盘价 × (1 + 滑点 ${pct(costs.slippage_rate)})`}
/>
<SpecRow
k="卖出价"
v={`收盘价 × (1 − 滑点 ${pct(costs.slippage_rate)})`}
/>
<SpecRow k="回测区间" v={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"} mono />
<SpecRow
k="初始资金"
v={
spec.initial_capital != null
? `${Number(spec.initial_capital).toLocaleString("zh-CN")} 元`
: "—"
}
/>
</SpecBlock>
<SpecBlock title="成本">
<SpecRow k="佣金" v={`${pct(costs.commission_rate)}(买入与卖出各计一次)`} />
<SpecRow k="印花税" v={`${pct(costs.stamp_tax_rate)}(仅卖出)`} />
<SpecRow k="滑点" v={pct(costs.slippage_rate)} />
<SpecRow
k="最低佣金"
v={
Number(costs.min_commission ?? 0) > 0
? `${costs.min_commission} 元/笔(max(按比例, 最低))`
: "未启用(0)"
}
/>
<SpecRow
k="对照基准"
v={String(costs.benchmark ?? "000300.SH") + "(仅展示,不参与交易)"}
/>
</SpecBlock>
<SpecBlock title="成交约束(近似建模)">
<SpecRow k="涨停" v="收盘价 / 上一有效收盘 ≥ 板块涨停系数 → 不可买入" />
<SpecRow k="跌停" v="→ 不可卖出(持仓保留到下一次调仓)" />
<SpecRow k="停牌" v="当日无行情 → 不可买不可卖" />
<SpecRow k="顺延买入" v={sel.defer_buy ? "启用(到下次调仓仍未成交则作废,资金留作现金)" : "未启用"} />
<SpecRow k="期末平仓" v="否 —— 最后持仓按最后交易日收盘估值,浮盈浮亏计入净值但不产生 Trade 记录" />
</SpecBlock>
<SpecBlock title="复现提示">
<div className="hint">
① 同样的 spec + 同样的代码版本 + 同样的数据版本才应复现同样结果;
② 复权口径为 {adjustLabel(spec.price_adjustment as string)},
{spec.price_adjustment === "none"
? "现金分红未计入收益、除权日价格下移会记为亏损 —— 股息类策略建议用 hfq 重跑对比。"
: "分红按复权因子隐含再投资处理。"}
③ 未建模项(涨跌停开盘路径、停牌明细、流动性冲击等)写在该归档结果的
<RichText text="`unimplemented`" /> 列表里,下方结果区已如实展示。
</div>
</SpecBlock>
</div>
{detail.kind !== "backtest" ? (
<div className="hint" style={{ marginTop: 10 }}>
该归档类型为「{experimentKindLabel(detail.kind)}」,其 spec 中的择股/调仓/成本字段不参与计算。
</div>
) : null}
</Card>
</>
);
}
function SpecBlock({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="spec-block">
<div className="spec-block__title">{title}</div>
{children}
</div>
);
}
function SpecRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
return (
<div className="spec-row">
<span className="spec-row__k">{k}</span>
<span className={`spec-row__v${mono ? " mono" : ""}`}>
<RichText text={v} />
</span>
</div>
);
}
function pct(v: unknown): string {
const n = Number(v);
if (!Number.isFinite(n)) return "未记录";
return `${(n * 100).toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}%`;
}