155 lines
6.9 KiB
TypeScript
155 lines
6.9 KiB
TypeScript
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Fragment, useState } from "react";
|
|
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
|
|
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
export const Route = createFileRoute("/core-stocks")({
|
|
component: CoreStocksPage,
|
|
});
|
|
|
|
/** 格式化涨幅,红涨绿跌 */
|
|
function formatGain(v: number | null | undefined): string {
|
|
if (v == null) return "·";
|
|
const s = v >= 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
|
|
return s;
|
|
}
|
|
|
|
function CoreStocksPage() {
|
|
const [days, setDays] = useState(10);
|
|
const { data, isLoading, isFetching, refetch } = useQuery({
|
|
queryKey: ["core-stocks", "active", days],
|
|
queryFn: () => fetchActiveCoreStocks(days),
|
|
staleTime: 60_000,
|
|
retry: false,
|
|
});
|
|
|
|
const dates = data?.dates ?? [];
|
|
const stocks = data?.stocks ?? [];
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
{/* 顶栏 */}
|
|
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
|
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Link to="/hot-map" className="hover:opacity-70 transition-opacity" aria-label="返回">
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Link>
|
|
<h1 className="text-base font-semibold">热点股追踪</h1>
|
|
</div>
|
|
<button
|
|
onClick={() => refetch()}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
title="刷新"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
活跃热点股(最近 {days} 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
|
|
</p>
|
|
<Tabs value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
|
<TabsList>
|
|
<TabsTrigger value="3">近3日</TabsTrigger>
|
|
<TabsTrigger value="5">近5日</TabsTrigger>
|
|
<TabsTrigger value="10">近10日</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
|
) : dates.length === 0 || stocks.length === 0 ? (
|
|
<div className="text-center text-sm text-muted-foreground py-16">
|
|
暂无数据,数据将在每日收盘后自动采集
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b bg-muted/50">
|
|
<th scope="col" className="px-3 py-2 text-left font-medium whitespace-nowrap">股票</th>
|
|
{dates.map((d) => (
|
|
<th key={d} scope="col" title={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
|
|
{d.slice(5)}
|
|
</th>
|
|
))}
|
|
<th scope="col" className="px-2 py-2 text-right font-medium" title="覆盖题材数">题材数</th>
|
|
<th scope="col" className="px-2 py-2 text-right font-medium whitespace-nowrap">最近上榜</th>
|
|
<th scope="col" className="px-2 py-2 text-right font-medium">上榜</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{stocks.map((s) => (
|
|
<Fragment key={s.stockCode}>
|
|
<tr className="border-b last:border-0 hover:bg-muted/30">
|
|
<td className="px-3 py-1.5 whitespace-nowrap">
|
|
<Link
|
|
to="/stock/$code"
|
|
params={{ code: s.stockCode }}
|
|
className="font-medium hover:text-primary hover:underline"
|
|
>
|
|
{s.stockName}
|
|
</Link>
|
|
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
|
|
</td>
|
|
{dates.map((d) => {
|
|
const g = s.dailyGains[d];
|
|
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
|
|
return (
|
|
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
|
|
{formatGain(g)}
|
|
</td>
|
|
);
|
|
})}
|
|
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
|
|
{s.coverCount ?? "·"}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
|
|
{s.lastAppear ? s.lastAppear.slice(5) : "·"}
|
|
</td>
|
|
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
|
<span className="inline-flex items-center gap-0.5 text-orange-500">
|
|
<Flame className="h-3 w-3" />
|
|
{s.appearCount}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
<tr className="border-b bg-muted/20">
|
|
<td colSpan={dates.length + 4} className="px-3 py-2">
|
|
<div className="text-[11px] text-muted-foreground mb-1">所属题材</div>
|
|
{s.themes && s.themes.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{s.themes.map((t) => (
|
|
<Link
|
|
key={t.theme_code}
|
|
to="/theme/$code"
|
|
params={{ code: t.theme_code }}
|
|
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] hover:bg-primary/20 transition-colors"
|
|
>
|
|
{t.theme_name}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">暂无题材数据</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
</Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|