Files
qlib/frontend/web/app/stocks/page.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

207 lines
5.4 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.
"use client";
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);
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 (
<>
<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={setQ}
onClear={() => setQ("")}
placeholder="按代码 / 名称搜索,如 600519 或 茅台"
label="搜索股票"
/>
</div>
<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>
</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,
}}
/>
);
}