- 个股统一研究页:K 线(chart API,none/qfq/hfq 显示切换)+ 量 + MA + 成交 fills/未成交信号标记 + 该股历史选股命中与信号记录(点击看原因/价格) - 股票池每行代码可点击进入本页 - 数据全部来自 Chart API(前端不重算);类型补齐 Stock.market/exchange - 冒烟:/stocks/600519.SH 200,MySQL 上 qfq K 线返回正常;tsc --noEmit 通过
208 lines
5.6 KiB
TypeScript
208 lines
5.6 KiB
TypeScript
"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,
|
||
}}
|
||
/>
|
||
);
|
||
}
|