feat(web): 按 ui-ux-pro-max 设计系统重构前端(侧边导航 + 组件库 + 图标 + 可视化)

- 设计令牌与组件体系:语义色/间距/圆角/状态 + Card/Metric/Pill/Btn/Field/Empty/Skeleton/Progress 等
- 桌面左侧导航 + 窄屏抽屉(usePathname 高亮、ESC/遮罩关闭);修复抽屉/遮罩占据网格把 main 挤到第二行的问题
- 自绘内联 SVG 图标集替代 emoji;图标/表格/按钮等交互态齐全,空态/加载/错误/禁用态补齐
- 因子研究分层柱状、因子组合与回测净值/回撤双图 + 月度收益热力表、实验页任务状态徽标
- LineChart 主题化并按需引入 echarts(回测/组合首载 JS 448k→281k);next.config 放行局域网 dev 来源
This commit is contained in:
Simon
2026-09-08 21:48:20 +08:00
parent 442999f701
commit 24f98e90c6
13 changed files with 3251 additions and 701 deletions
+70 -13
View File
@@ -1,49 +1,106 @@
"use client";
/** 基于 ECharts 的折线图(净值 / 回撤),按需引入模块以压缩 bundle。 */
import { useEffect, useRef } from "react";
import * as echarts from "echarts";
import * as echarts from "echarts/core";
import { LineChart as EChartsLine } from "echarts/charts";
import { GridComponent, TitleComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
echarts.use([EChartsLine, GridComponent, TitleComponent, TooltipComponent, CanvasRenderer]);
export interface XY {
date: string;
value: number;
}
const C = {
text: "#a8b3c9",
faint: "#8b98b2",
line: "rgba(150,165,195,0.13)",
tooltipBg: "#141d30",
tooltipBorder: "rgba(150,165,195,0.28)",
};
export function LineChart({
title,
data,
color = "#38bdf8",
color = "#3fb6ff",
fill = false,
height = 320,
yFmt,
xFmt,
}: {
title: string;
title?: string;
data: XY[];
color?: string;
/** 线下渐变面积(净值类曲线推荐开启) */
fill?: boolean;
height?: number;
yFmt?: (v: number) => string;
xFmt?: (v: string) => string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current || data.length === 0) return;
const chart = echarts.init(ref.current);
const areaColor = {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: color + "3d" },
{ offset: 1, color: color + "00" },
],
};
chart.setOption({
title: { text: title, textStyle: { color: "#e2e8f0", fontSize: 13 } },
tooltip: { trigger: "axis" },
grid: { left: 60, right: 20, top: 40, bottom: 30 },
backgroundColor: "transparent",
title: title
? {
text: title,
textStyle: { color: "#e9eef7", fontSize: 13, fontWeight: 600 },
}
: undefined,
tooltip: {
trigger: "axis",
backgroundColor: C.tooltipBg,
borderColor: C.tooltipBorder,
textStyle: { color: "#e9eef7", fontSize: 12 },
valueFormatter: (v: unknown) =>
typeof v === "number" ? (yFmt ? yFmt(v) : String(v)) : String(v),
},
grid: { left: 8, right: 14, top: title ? 42 : 18, bottom: 8, containLabel: true },
xAxis: {
type: "category",
boundaryGap: false,
data: data.map((p) => p.date),
axisLabel: { color: "#94a3b8", fontSize: 10 },
axisLine: { lineStyle: { color: C.line } },
axisTick: { show: false },
axisLabel: {
color: C.faint,
fontSize: 10,
formatter: (v: string) => (xFmt ? xFmt(v) : v),
},
},
yAxis: {
type: "value",
scale: true,
axisLabel: { color: "#94a3b8" },
splitLine: { lineStyle: { color: "#334155" } },
axisLabel: { color: C.faint, fontSize: 10, formatter: yFmt },
splitLine: { lineStyle: { color: C.line } },
},
series: [
{
type: "line",
showSymbol: false,
symbol: "circle",
data: data.map((p) => p.value),
lineStyle: { color, width: 1.6 },
lineStyle: { color, width: 1.8 },
itemStyle: { color },
areaStyle: fill ? { color: areaColor } : undefined,
emphasis: { focus: "series" },
},
],
});
@@ -53,8 +110,8 @@ export function LineChart({
window.removeEventListener("resize", onResize);
chart.dispose();
};
}, [data, color, title]);
}, [data, color, fill, title, height, yFmt, xFmt]);
if (data.length === 0) return <div className="muted">暂无数据</div>;
return <div ref={ref} style={{ height: 360, width: "100%" }} />;
if (data.length === 0) return <div className="hint">暂无数据</div>;
return <div ref={ref} style={{ height, width: "100%" }} />;
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
/** 应用外壳:桌面左侧导航栏 + 窄屏顶栏与抽屉(usePathname 高亮当前页)。 */
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Icon, type IconName } from "@/components/icons";
interface NavItem {
href: string;
label: string;
icon: IconName;
}
const GROUPS: { title: string; items: NavItem[] }[] = [
{
title: "研究",
items: [
{ href: "/", label: "总览", icon: "grid" },
{ href: "/stocks", label: "股票池", icon: "candles" },
{ href: "/factors", label: "因子研究", icon: "flask" },
{ href: "/factors/compose", label: "因子组合", icon: "layers" },
{ href: "/backtest", label: "选股回测", icon: "gauge" },
],
},
{
title: "归档",
items: [{ href: "/experiments", label: "实验", icon: "archive" }],
},
];
function isActive(href: string, pathname: string): boolean {
if (href === "/") return pathname === "/";
if (href === "/factors") return pathname === "/factors";
if (pathname === href || pathname.startsWith(href + "/")) return true;
return false;
}
function Brand() {
return (
<Link href="/" className="brand" style={{ textDecoration: "none" }}>
<span className="logo">
<Icon name="logo" size={18} />
</span>
<span>
qlib-platform
<small>个人 A 股量化研究台</small>
</span>
</Link>
);
}
function NavLinks({ pathname, onNavigate }: { pathname: string; onNavigate?: () => void }) {
return (
<>
{GROUPS.map((g) => (
<div key={g.title}>
<div className="nav-label">{g.title}</div>
{g.items.map((item) => {
const active = isActive(item.href, pathname);
return (
<Link
key={item.href}
href={item.href}
className={active ? "nav-link active" : "nav-link"}
aria-current={active ? "page" : undefined}
onClick={onNavigate}
>
<span className="nav-ic">
<Icon name={item.icon} size={17} />
</span>
{item.label}
</Link>
);
})}
</div>
))}
</>
);
}
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
// 路由变化时收起抽屉
useEffect(() => {
setOpen(false);
}, [pathname]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open]);
return (
<div className="shell">
<aside className="rail nav" aria-label="主导航">
<Brand />
<NavLinks pathname={pathname} />
<div className="rail-foot">
数据源 Tushare 首选 · 新浪校验兜底
<br />
研究引擎 Qlib(本地 SQLite / Parquet)
</div>
</aside>
<div className="topbar">
<button
type="button"
className="icon-btn"
aria-label="打开导航菜单"
aria-expanded={open}
onClick={() => setOpen(true)}
style={{ width: 36, height: 36 }}
>
<Icon name="filter" size={18} />
</button>
<Brand />
</div>
{open ? (
<div className="scrim show" aria-hidden="true" onClick={() => setOpen(false)} />
) : null}
<aside
className={open ? "drawer nav open" : "drawer nav"}
aria-label="移动端导航"
aria-hidden={!open}
>
{open ? <NavLinks pathname={pathname} onNavigate={() => setOpen(false)} /> : null}
</aside>
<main className="content">
<div className="page">{children}</div>
</main>
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
/**
* 内联 SVG 图标集(Phosphor 风格线性图标,1.6px 描边,stroke=currentColor)。
* 不引入第三方图标库(离线可用),禁止用 emoji 作结构图标。
*
* 用法:<Icon name="search" size={16} /> 或 <Icon.Home .../>
*/
import type { ReactNode, SVGProps } from "react";
export type IconName =
| "logo"
| "grid"
| "candles"
| "flask"
| "layers"
| "gauge"
| "archive"
| "search"
| "x"
| "chevDown"
| "chevRight"
| "arrowUpRight"
| "arrowUp"
| "arrowDown"
| "play"
| "refresh"
| "spinner"
| "alert"
| "check"
| "info"
| "database"
| "calendar"
| "target"
| "chartLine"
| "scale"
| "book"
| "link"
| "spark"
| "clock"
| "filter";
const P: Record<
IconName,
(p: { filled?: boolean }) => ReactNode
> = {
logo: () => (
<>
<path d="M4 20V4h4v6h4V4h4v10h4V4h4v16H4z" fill="currentColor" stroke="none" />
<path d="M3 20h18" strokeWidth="2.4" strokeLinecap="round" />
</>
),
grid: () => (
<>
<rect x="3.5" y="3.5" width="7" height="7" rx="1.6" />
<rect x="13.5" y="3.5" width="7" height="7" rx="1.6" />
<rect x="3.5" y="13.5" width="7" height="7" rx="1.6" />
<rect x="13.5" y="13.5" width="7" height="7" rx="1.6" />
</>
),
candles: () => (
<>
<path d="M7 3v3.2M7 17.8V21M7 6.2a1.8 1.8 0 0 1 1.8 1.8v6a1.8 1.8 0 0 1-1.8 1.8v0a1.8 1.8 0 0 1-1.8-1.8V8a1.8 1.8 0 0 1 1.8-1.8z" />
<path d="M17 3v2M17 19v2M17 5a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2v0a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z" />
</>
),
flask: () => (
<>
<path d="M9.5 3h5M10.5 3v6.2L5.2 18.6A2 2 0 0 0 6.9 21.5h10.2a2 2 0 0 0 1.7-2.9L13.5 9.2V3" />
<path d="M7.2 15.5h9.6" />
</>
),
layers: () => (
<>
<path d="M12 3.5 21 8.5l-9 5-9-5 9-5z" />
<path d="m4.5 12.5 7.5 4.2 7.5-4.2M4.5 16.5l7.5 4.2 7.5-4.2" />
</>
),
gauge: () => (
<>
<path d="M12 21a9 9 0 1 1 9-9" />
<path d="M12 12.5 16.6 6.8" />
<circle cx="12" cy="12.5" r="1.5" fill="currentColor" stroke="none" />
</>
),
archive: () => (
<>
<rect x="3.5" y="4" width="17" height="5" rx="1.6" />
<path d="M5.5 9v9.5a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V9" />
<path d="M10 13.5h4" />
</>
),
search: () => (
<>
<circle cx="11" cy="11" r="6.5" />
<path d="m20 20-4.4-4.4" strokeLinecap="round" />
</>
),
x: () => (
<>
<path d="m6 6 12 12M18 6 6 18" strokeLinecap="round" />
</>
),
chevDown: () => <path d="m6 9.5 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />,
chevRight: () => <path d="m9.5 6 6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />,
arrowUpRight: () => (
<>
<path d="M7 17 17 7M9 7h8v8" strokeLinecap="round" strokeLinejoin="round" />
</>
),
arrowUp: () => <path d="M12 19V5M5.5 11.5 12 5l6.5 6.5" strokeLinecap="round" strokeLinejoin="round" />,
arrowDown: () => <path d="M12 5v14M18.5 12.5 12 19l-6.5-6.5" strokeLinecap="round" strokeLinejoin="round" />,
play: ({ filled }) =>
filled ? (
<path d="M8 5.5v13a1 1 0 0 0 1.5.9l11-6.5a1 1 0 0 0 0-1.8l-11-6.5A1 1 0 0 0 8 5.5z" fill="currentColor" stroke="none" />
) : (
<path d="M8.5 5.8v12.4a.6.6 0 0 0 .9.5l10-6.2a.6.6 0 0 0 0-1L9.4 5.3a.6.6 0 0 0-.9.5z" fill="currentColor" stroke="none" />
),
refresh: () => (
<>
<path d="M20 12a8 8 0 1 1-2.3-5.6" strokeLinecap="round" />
<path d="M20 3.5V6.6a.4.4 0 0 1-.4.4H16.5" strokeLinecap="round" strokeLinejoin="round" />
</>
),
spinner: () => (
<>
<path d="M12 3a9 9 0 1 0 9 9" strokeLinecap="round" />
</>
),
alert: ({ filled }) =>
filled ? (
<path
d="M12 2.6 23 20.8a1 1 0 0 1-.9 1.5H1.9a1 1 0 0 1-.9-1.5L12 2.6zM12 10v4.6m0 3.1v.1"
fill="currentColor"
stroke="none"
opacity="0.25"
/>
) : (
<>
<path d="M12 3.2 21.7 20a1 1 0 0 1-.85 1.5H3.15A1 1 0 0 1 2.3 20L12 3.2z" />
<path d="M12 9.5v4.4M12 17.4v.1" strokeLinecap="round" />
</>
),
check: () => <path d="m5 12.5 4.5 4.5L19 7.5" strokeLinecap="round" strokeLinejoin="round" />,
info: () => (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5.5M12 7.6v.1" strokeLinecap="round" />
</>
),
database: () => (
<>
<ellipse cx="12" cy="5.5" rx="7.5" ry="3" />
<path d="M4.5 5.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
<path d="M4.5 11.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
</>
),
calendar: () => (
<>
<rect x="3.5" y="5" width="17" height="15.5" rx="2" />
<path d="M8 3v4M16 3v4M3.5 10h17" />
</>
),
target: () => (
<>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="12" r="4.5" />
<circle cx="12" cy="12" r="0.8" fill="currentColor" stroke="none" />
</>
),
chartLine: () => (
<>
<path d="M3.5 3.5v17h17" />
<path d="m6.5 15 4-5 3.5 3 4.5-6.5" strokeLinecap="round" strokeLinejoin="round" />
</>
),
scale: () => (
<>
<path d="M12 4v16M8 20h8" />
<path d="m5 8 7-3 7 3-2.2 3H7.2L5 8z" />
<path d="M3.5 12.2 2 11l3-3M20.5 12.2 22 11l-3-3" strokeLinecap="round" strokeLinejoin="round" />
</>
),
book: () => (
<>
<path d="M4.5 5A2.5 2.5 0 0 1 7 2.5h12V18H7a2.5 2.5 0 0 0-2.5 2.5V5z" />
<path d="M4.5 20.5A2.5 2.5 0 0 1 7 18h12" />
<path d="M9.5 7h6" strokeLinecap="round" />
</>
),
link: () => (
<>
<path d="M10 14.5 14.5 10" strokeLinecap="round" />
<path d="M8.2 15.8 5.8 18.2a2.8 2.8 0 1 1-3.9-3.9l4-4a2.8 2.8 0 0 1 3.9 0" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15.8 8.2l2.4-2.4a2.8 2.8 0 1 1 3.9 3.9l-4 4a2.8 2.8 0 0 1-3.9 0" strokeLinecap="round" strokeLinejoin="round" />
</>
),
spark: () => (
<>
<path d="M12 3.5 13.8 9l5.7 1.4-5.7 1.4L12 17.5l-1.8-5.7L4.5 10.4 10.2 9 12 3.5z" fill="currentColor" stroke="none" opacity="0.35" />
<path d="M19 15.5l.9 2.6 2.6.9-2.6.9-.9 2.6-.9-2.6-2.6-.9 2.6-.9.9-2.6z" fill="currentColor" stroke="none" />
</>
),
clock: () => (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" strokeLinecap="round" strokeLinejoin="round" />
</>
),
filter: () => (
<>
<path d="M4 6h16M7 12h10M10 18h4" strokeLinecap="round" />
</>
),
};
interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
name: IconName;
size?: number;
/** 填充态(部分图标支持实心强调) */
filled?: boolean;
}
export function Icon({ name, size = 18, filled = false, ...rest }: IconProps) {
return (
<svg
className="ic"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...rest}
>
{P[name]({ filled })}
</svg>
);
}
+449
View File
@@ -0,0 +1,449 @@
/**
* 设计系统 UI 基元(纯展示,无副作用):页头 / 卡片 / 指标卡 / 徽标 /
* 按钮 / 表单字段 / 提示条 / 空态 / 骨架 / 进度 / 回测结果区块。
*/
import { useId } from "react";
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react";
import type { BacktestSummary } from "@/lib/types";
import { Icon, type IconName } from "@/components/icons";
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ---------- 页头 ---------- */
export function PageHeader({
title,
sub,
actions,
}: {
title: string;
sub?: ReactNode;
actions?: ReactNode;
}) {
return (
<header className="page-head">
<div>
<h1 className="page-title">{title}</h1>
{sub ? <p className="page-sub">{sub}</p> : null}
</div>
{actions ? <div className="page-actions">{actions}</div> : null}
</header>
);
}
/* ---------- 卡片 ---------- */
export function Card({
title,
sub,
icon,
tools,
children,
flush = false,
className,
}: {
title?: ReactNode;
sub?: ReactNode;
icon?: IconName;
tools?: ReactNode;
children: ReactNode;
flush?: boolean;
className?: string;
}) {
const hasHead = title != null || tools != null;
return (
<section className={cx("card", className)}>
{hasHead ? (
<div className="card__head">
<div className="card__title">
{icon ? <Icon name={icon} size={17} /> : null}
<span>
{title}
{sub ? <span className="card__sub"> {sub}</span> : null}
</span>
</div>
{tools ? <div className="card__tools">{tools}</div> : null}
</div>
) : null}
<div className={flush ? "card__body--flush" : "card__body"}>{children}</div>
</section>
);
}
/* ---------- 指标卡 ---------- */
export function Metric({
label,
icon,
value,
sub,
tone = "plain",
small = false,
}: {
label: ReactNode;
icon?: IconName;
value: ReactNode;
sub?: ReactNode;
tone?: "plain" | "pos" | "neg" | "warn" | "accent";
small?: boolean;
}) {
return (
<div className="metric">
<div className="metric__label">
{icon ? <Icon name={icon} size={14} /> : null}
{label}
</div>
<div
className={cx(
"metric__value",
small && "metric__value--sm",
tone !== "plain" && `tone-${tone}`,
)}
>
{value}
</div>
{sub ? <div className="metric__sub">{sub}</div> : null}
</div>
);
}
/* ---------- 徽标 ---------- */
export function Pill({
tone = "default",
children,
icon,
}: {
tone?: "default" | "accent" | "pos" | "neg" | "warn" | "violet";
children: ReactNode;
icon?: IconName;
}) {
return (
<span className={cx("pill", tone !== "default" && `pill--${tone}`)}>
{icon ? <Icon name={icon} size={12} /> : null}
{children}
</span>
);
}
/* ---------- 按钮 ---------- */
interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "ghost" | "danger";
size?: "sm" | "md";
icon?: IconName;
loading?: boolean;
children?: ReactNode;
}
export function Btn({
variant = "ghost",
size = "md",
icon,
loading = false,
children,
className,
...rest
}: BtnProps) {
return (
<button
className={cx(
"btn",
variant === "primary" && "btn--primary",
variant === "danger" && "btn--danger",
size === "sm" && "btn--sm",
className,
)}
disabled={rest.disabled || loading}
{...rest}
>
{loading ? <Icon name="spinner" size={14} className="spin" /> : icon ? <Icon name={icon} size={15} /> : null}
{children}
</button>
);
}
/* ---------- 表单 ---------- */
export function Field({
label,
hint,
htmlFor,
children,
className,
}: {
label: ReactNode;
hint?: ReactNode;
htmlFor?: string;
children: ReactNode;
className?: string;
}) {
return (
<div className={cx("field", className)}>
<label className="field__label" htmlFor={htmlFor}>
{label}
</label>
{children}
{hint ? <span className="field__hint">{hint}</span> : null}
</div>
);
}
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
mono?: boolean;
}
/** 统一样式的 input(含原生控件),mono 用于代码/数值输入。 */
export function TextInput({ className, mono, ...rest }: TextInputProps) {
return <input className={cx("input", mono && "mono", className)} {...rest} />;
}
export function SearchBox({
value,
onChange,
onClear,
placeholder,
label,
}: {
value: string;
onChange: (v: string) => void;
onClear?: () => void;
placeholder?: string;
label?: string;
}) {
const inputId = useId();
return (
<div className="searchbox" role="search">
<label className="sr-only" htmlFor={inputId}>
{label ?? "搜索"}
</label>
<Icon name="search" size={16} />
<TextInput
id={inputId}
type="search"
className="input"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{value && onClear ? (
<button type="button" className="icon-btn clear" aria-label="清空搜索" onClick={onClear}>
<Icon name="x" size={13} />
</button>
) : null}
</div>
);
}
/* ---------- 提示条 / 空态 / 加载 ---------- */
export function Banner({
tone = "info",
children,
icon,
}: {
tone?: "error" | "warn" | "info" | "muted";
children: ReactNode;
icon?: IconName;
}) {
const ic: IconName =
icon ?? (tone === "error" || tone === "warn" ? "alert" : tone === "muted" ? "info" : "info");
return (
<div className={cx("banner", `banner--${tone}`)} role={tone === "error" ? "alert" : undefined}>
<Icon name={ic} size={16} />
<div>{children}</div>
</div>
);
}
export function Empty({
icon = "archive",
title,
hint,
children,
}: {
icon?: IconName;
title: ReactNode;
hint?: ReactNode;
children?: ReactNode;
}) {
return (
<div className="empty">
<Icon name={icon} size={30} />
<strong>{title}</strong>
{hint ? <p className="hint" style={{ maxWidth: 460, margin: "4px auto 0" }}>{hint}</p> : null}
{children ? <div style={{ marginTop: 14 }}>{children}</div> : null}
</div>
);
}
export function SkeletonLines({ n = 3, height = 14 }: { n?: number; height?: number }) {
return (
<div className="stack" aria-hidden="true">
{Array.from({ length: n }).map((_, i) => (
<div
key={i}
className="skeleton"
style={{ height, width: i === n - 1 ? "62%" : "100%" }}
/>
))}
</div>
);
}
export function Loading({ label = "加载中…" }: { label?: string }) {
return (
<div className="row" style={{ color: "var(--text-2)", padding: "10px 0" }} role="status">
<Icon name="spinner" size={16} className="spin" />
<span className="hint">{label}</span>
</div>
);
}
export function Progress({
value,
label,
}: {
/** 0-100 */
value: number;
label?: ReactNode;
}) {
return (
<div>
{label ? (
<div className="hint" style={{ marginBottom: 2 }}>
{label}
</div>
) : null}
<div
className="progress"
role="progressbar"
aria-valuenow={Math.round(value)}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="progress__bar" style={{ width: `${Math.max(0, Math.min(100, value))}%` }} />
</div>
</div>
);
}
/* ---------- 数值 ---------- */
export function Signed({
value,
suffix = "",
digits = 2,
arrow = true,
}: {
value: number;
suffix?: string;
digits?: number;
arrow?: boolean;
}) {
const cls = value > 0 ? "tone-pos" : value < 0 ? "tone-neg" : "";
return (
<span className={cx("num delta", cls)}>
{arrow ? (value > 0 ? <Icon name="arrowUp" size={12} /> : value < 0 ? <Icon name="arrowDown" size={12} /> : null) : null}
{value > 0 ? "+" : ""}
{value.toFixed(digits)}
{suffix}
</span>
);
}
/* ---------- 回测 / 因子结果共用区块 ---------- */
export function BacktestMetrics({ s }: { s: BacktestSummary }) {
return (
<div className="metric-grid">
<Metric label="期末净值" value={s.final_equity.toLocaleString()} sub={`起始 ${s.initial_capital.toLocaleString()}`} />
<Metric label="区间收益" tone={s.total_return_pct >= 0 ? "pos" : "neg"} value={<Signed value={s.total_return_pct} suffix="%" />} sub={`${s.start} ~ ${s.end}`} />
<Metric label="年化收益" tone={s.annual_return_pct >= 0 ? "pos" : "neg"} value={<Signed value={s.annual_return_pct} suffix="%" />} />
<Metric
label="Sharpe"
tone={s.sharpe >= 1 ? "pos" : s.sharpe < 0 ? "neg" : "plain"}
value={s.sharpe.toFixed(2)}
sub={`波动 ${s.volatility_pct.toFixed(2)}%`}
/>
<Metric label="最大回撤" tone="neg" value={<span className="num">{s.max_drawdown_pct.toFixed(2)}%</span>} />
<Metric label="胜率 / 换手" value={`${s.win_rate_pct.toFixed(1)}%`} sub={`交易 ${s.total_trades} 笔 · 平均换手 ${s.avg_turnover_pct.toFixed(0)}%`} />
</div>
);
}
/* ---------- 月度收益(热力着色) ---------- */
export function MonthlyReturnsTable({
rows,
}: {
rows: { year: number; month: number; return_pct: number }[];
}) {
const byYear = new Map<number, Map<number, number>>();
for (const r of rows) {
if (!byYear.has(r.year)) byYear.set(r.year, new Map());
byYear.get(r.year)!.set(r.month, r.return_pct);
}
const months = Array.from({ length: 12 }, (_, i) => i + 1);
const years = [...byYear.keys()].sort();
if (years.length === 0) return <Empty title="无月度收益数据" />;
return (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>年份</th>
{months.map((m) => (
<th key={m} style={{ textAlign: "right", minWidth: 62 }}>
{m}月
</th>
))}
</tr>
</thead>
<tbody>
{years.map((y) => {
const row = byYear.get(y)!;
return (
<tr key={y}>
<td className="cell-strong">{y}</td>
{months.map((m) => {
const v = row.get(m);
if (v == null)
return (
<td key={m} style={{ textAlign: "right" }}>
<span className="cell-heat flat">—</span>
</td>
);
const cls = v > 0 ? "pos" : v < 0 ? "neg" : "flat";
return (
<td key={m} style={{ textAlign: "right" }}>
<span className={`cell-heat ${cls}`}>
{v > 0 ? "+" : ""}
{v.toFixed(2)}%
</span>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
export function UnimplementedNote({ items }: { items: string[] }) {
if (!items || items.length === 0) return null;
return (
<Card title="未建模约束(如实标注)" icon="info">
<ul className="hint" style={{ margin: 0, paddingLeft: 18 }}>
{items.map((u, i) => (
<li key={i}>{u}</li>
))}
</ul>
</Card>
);
}