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
+185 -37
View File
@@ -1,58 +1,206 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiGet } from "@/lib/api";
import type { Stock } from "@/lib/types";
import {
PageHeader,
Card,
Banner,
Empty,
Pill,
SkeletonLines,
SearchBox,
} from "@/components/ui";
import { Icon } from "@/components/icons";
const LIMIT = 300;
function marketOf(symbol: string): string {
return symbol.slice(-2);
}
function statusView(s: Stock) {
switch (s.status) {
case "L":
return (
<Pill tone="pos">
上市
</Pill>
);
case "D":
return <Pill tone="neg">退市</Pill>;
case "P":
return <Pill tone="warn">暂停</Pill>;
default:
return <Pill>{s.status}</Pill>;
}
}
export default function StocksPage() {
const [stocks, setStocks] = useState<Stock[]>([]);
const [q, setQ] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [reloadKey, setReloadKey] = useState(0);
const first = useRef(true);
useEffect(() => {
apiGet<Stock[]>(`/stocks?limit=300&q=${encodeURIComponent(q)}`)
.then(setStocks)
.catch((e: Error) => setError(e.message));
const load = useCallback(async () => {
setError("");
try {
const list = await apiGet<Stock[]>(
`/stocks?limit=${LIMIT}&q=${encodeURIComponent(q)}`,
);
setStocks(list);
} catch (e) {
setError((e as Error).message);
setStocks([]);
} finally {
setLoading(false);
}
}, [q]);
// 输入防抖 250ms 后再请求
useEffect(() => {
if (first.current) {
first.current = false;
void load();
return;
}
setLoading(true);
const t = window.setTimeout(() => void load(), 250);
return () => window.clearTimeout(t);
}, [q, reloadKey, load]);
return (
<>
<h1>股票池</h1>
<div className="row" style={{ marginBottom: 12 }}>
<input
placeholder="按代码 / 名称搜索(如 600519 或 茅台)"
<PageHeader
title="股票池"
sub="本地股票基础信息(Tushare 同步)。支持按代码 / 名称模糊搜索,最多展示 300 条。"
actions={
<>
<Pill tone="accent" icon="candles">
共 {stocks.length} 条
</Pill>
<button
type="button"
className="btn btn--sm"
aria-label="刷新列表"
onClick={() => {
setLoading(true);
setReloadKey((k) => k + 1);
}}
>
<Icon name="refresh" size={14} />
刷新
</button>
</>
}
/>
{error ? (
<Banner tone="error">加载失败:{error}</Banner>
) : null}
<div className="row" style={{ marginBottom: 14 }}>
<SearchBox
value={q}
onChange={(e) => setQ(e.target.value)}
onChange={setQ}
onClear={() => setQ("")}
placeholder="按代码 / 名称搜索,如 600519 或 茅台"
label="搜索股票"
/>
<span className="muted">共 {stocks.length} 条</span>
</div>
{error && <div className="error">{error}</div>}
<div className="card">
<table>
<thead>
<tr>
<th>代码</th>
<th>名称</th>
<th>行业</th>
<th>地区</th>
<th>上市日</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{stocks.map((s) => (
<tr key={s.symbol}>
<td>{s.symbol}</td>
<td>{s.name}</td>
<td>{s.industry ?? "—"}</td>
<td>{s.area ?? "—"}</td>
<td>{s.list_date}</td>
<td>{s.status}</td>
<Card flush>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>代码</th>
<th>名称</th>
<th>市场</th>
<th>行业</th>
<th>地区</th>
<th>上市日</th>
<th>状态</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{stocks.map((s) => (
<tr key={s.symbol}>
<td className="cell-mono cell-strong">{s.symbol}</td>
<td>{s.name}</td>
<td>
<span className="tag tag--mono">{marketOf(s.symbol)}</span>
</td>
<td>{s.industry ? <span className="tag">{s.industry}</span> : <span className="hint">—</span>}</td>
<td className="hint">{s.area ?? "—"}</td>
<td className="cell-mono">{s.list_date}</td>
<td>{statusView(s)}</td>
</tr>
))}
</tbody>
</table>
</div>
{loading && !error && stocks.length === 0 ? (
<div style={{ padding: 18 }}>
<SkeletonLines n={6} />
</div>
) : null}
{!loading && !error && stocks.length === 0 ? (
<Empty
icon="search"
title={q ? "没有匹配的股票" : "股票池为空"}
hint={
q
? `「${q}」没有命中。试试更短的代码或名称关键词。`
: "请先通过后端同步基础信息:uv run python -m app.cli.sync basic"
}
/>
) : null}
{stocks.length > 0 ? (
<div
className="hint"
style={{
padding: "8px 16px",
borderTop: "1px solid var(--line)",
display: "flex",
alignItems: "center",
gap: 14,
}}
>
<span>显示前 {stocks.length} 条(接口上限 {LIMIT})</span>
<span className="row" style={{ gap: 4 }}>
<Dot color="var(--pos)" />
上市
</span>
<span className="row" style={{ gap: 4 }}>
<Dot color="var(--warn)" />
暂停
</span>
<span className="row" style={{ gap: 4 }}>
<Dot color="var(--neg)" />
退市
</span>
</div>
) : null}
</Card>
</>
);
}
function Dot({ color }: { color: string }) {
return (
<span
aria-hidden="true"
style={{
display: "inline-block",
width: 8,
height: 8,
borderRadius: "50%",
background: color,
}}
/>
);
}