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:
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user