Files
qlib/frontend/web/components/ui.tsx
T
Simon 24f98e90c6 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 来源
2026-09-08 21:48:20 +08:00

450 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 设计系统 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>
);
}