Files
qlib/frontend/web/app/stocks/page.tsx
T
Simon e1a0a8aa38 feat(web): M9-4 Stock Research Page /stocks/[symbol](v3 §20.4)
- 个股统一研究页:K 线(chart API,none/qfq/hfq 显示切换)+ 量 + MA +
  成交 fills/未成交信号标记 + 该股历史选股命中与信号记录(点击看原因/价格)
- 股票池每行代码可点击进入本页
- 数据全部来自 Chart API(前端不重算);类型补齐 Stock.market/exchange
- 冒烟:/stocks/600519.SH 200,MySQL 上 qfq K 线返回正常;tsc --noEmit 通过
2026-09-09 07:14:44 +08:00

208 lines
5.6 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 Link from "next/link";
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"><Link href={`/stocks/${s.symbol}`} style={{ textDecoration: "none" }}>{s.symbol}</Link></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,
}}
/>
);
}