页面标题、页面内'活跃核心股'描述、入口链接文案同步更名; 热点穿透页内'穿透核心股'列表概念(覆盖题材数)保留原名。 底层路由/表名/代码标识符 core_stocks 不变。 Co-Authored-By: Claude <noreply@anthropic.com>
252 lines
9.3 KiB
TypeScript
252 lines
9.3 KiB
TypeScript
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
import { useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { fetchThemes, type ThemeItem, type ThemeSortField } from "@/lib/theme-api";
|
|
import { getStockBoard } from "@/lib/stock-api";
|
|
import { formatMoney } from "@/lib/utils";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import {
|
|
ArrowLeft,
|
|
RefreshCw,
|
|
ArrowDown,
|
|
ArrowUp,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
Flame,
|
|
Network,
|
|
} from "lucide-react";
|
|
|
|
export const Route = createFileRoute("/themes")({
|
|
component: ThemesPage,
|
|
});
|
|
|
|
/* ============================================================
|
|
排序维度(sortField 与后端/东方财富对齐)
|
|
============================================================ */
|
|
const SORTS: { key: ThemeSortField; label: string }[] = [
|
|
{ key: 1, label: "涨幅" },
|
|
{ key: 3, label: "强度" },
|
|
{ key: 4, label: "热度" },
|
|
{ key: 5, label: "成交额" },
|
|
];
|
|
|
|
function ThemesPage() {
|
|
const [sortField, setSortField] = useState<ThemeSortField>(1);
|
|
const [asc, setAsc] = useState(false); // 默认降序
|
|
|
|
const { data: themes, isLoading, isFetching, isError, refetch } = useQuery({
|
|
queryKey: ["themes", sortField, asc],
|
|
queryFn: () => fetchThemes(sortField, asc),
|
|
staleTime: 30_000,
|
|
retry: false,
|
|
});
|
|
|
|
const data = themes ?? [];
|
|
|
|
const toggleSort = (key: ThemeSortField) => {
|
|
if (key === sortField) {
|
|
setAsc((v) => !v);
|
|
} else {
|
|
setSortField(key);
|
|
setAsc(false); // 切新维度默认降序
|
|
}
|
|
};
|
|
|
|
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="/" className="hover:opacity-70 transition-opacity">
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Link>
|
|
<h1 className="text-base font-semibold">题材热点</h1>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Link
|
|
to="/hot-map"
|
|
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
|
|
>
|
|
<Network className="h-3.5 w-3.5" />
|
|
热点穿透
|
|
</Link>
|
|
<Link
|
|
to="/core-stocks"
|
|
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
|
|
>
|
|
<Flame className="h-3.5 w-3.5" />
|
|
热点股
|
|
</Link>
|
|
<button
|
|
onClick={() => refetch()}
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
title="刷新"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* ── 排序切换 + 统计 ── */}
|
|
<div className="max-w-5xl mx-auto px-4 mt-3 flex items-center justify-between">
|
|
<p className="text-[10px] text-muted-foreground">
|
|
共 {data.length} 个题材
|
|
{isFetching && (
|
|
<span className="ml-1 text-[10px] text-muted-foreground/60">· 刷新中…</span>
|
|
)}
|
|
</p>
|
|
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
|
|
{SORTS.map((s) => (
|
|
<button
|
|
key={s.key}
|
|
onClick={() => toggleSort(s.key)}
|
|
className={`px-2.5 py-1 flex items-center gap-0.5 transition-colors ${
|
|
sortField === s.key
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{s.label}
|
|
{sortField === s.key &&
|
|
(asc ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 内容区 ── */}
|
|
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
|
{isLoading ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{Array.from({ length: 18 }).map((_, i) => (
|
|
<div key={i} className="animate-pulse rounded-xl bg-muted h-32" />
|
|
))}
|
|
</div>
|
|
) : isError ? (
|
|
<div className="flex flex-col items-center gap-3 py-20">
|
|
<p className="text-sm text-muted-foreground">数据加载失败</p>
|
|
<button onClick={() => refetch()} className="text-xs text-primary hover:underline">
|
|
点击重试
|
|
</button>
|
|
</div>
|
|
) : data.length === 0 ? (
|
|
<div className="text-center py-20 text-sm text-muted-foreground">暂无题材数据</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{data.map((item) => (
|
|
<ThemeCard key={item.themeCode} item={item} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================
|
|
数值格式化
|
|
============================================================ */
|
|
function fmt(val: number | null | undefined, digits = 2): string {
|
|
if (val == null) return "--";
|
|
return val.toFixed(digits);
|
|
}
|
|
|
|
/* ============================================================
|
|
题材卡片
|
|
============================================================ */
|
|
function ThemeCard({ item }: { item: ThemeItem }) {
|
|
const change = item.bf3;
|
|
const hotPct =
|
|
item.hotValueUpLimit > 0 ? Math.min((item.hotValue / item.hotValueUpLimit) * 100, 100) : 0;
|
|
const stockBoard = getStockBoard(item.securityCode);
|
|
|
|
return (
|
|
<Link to="/theme/$code" params={{ code: item.themeCode }} className="block">
|
|
<Card className="rounded-xl hover:shadow-md transition-shadow h-full">
|
|
<CardContent className="p-3 space-y-2">
|
|
{/* 题材名 + 领涨标签 */}
|
|
<div className="flex items-center justify-between gap-1">
|
|
<p className="text-sm font-medium truncate" title={item.themeName}>
|
|
{item.themeName}
|
|
</p>
|
|
{item.label && (
|
|
<span className="shrink-0 text-[9px] font-medium text-orange-500 bg-orange-500/10 border border-orange-500/30 rounded px-1 py-0.5">
|
|
{item.label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 热度进度条 */}
|
|
<div className="flex items-center gap-1">
|
|
<Flame className="h-3 w-3 text-orange-500 shrink-0" />
|
|
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
|
<div
|
|
className="h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
|
|
style={{ width: `${Math.max(hotPct, 2)}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
|
|
{item.hotValue}/{item.hotValueUpLimit}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 涨幅 + 成交额 */}
|
|
<div className="flex items-center justify-between">
|
|
{change != null ? (
|
|
<span
|
|
className={`inline-flex items-center gap-0.5 text-xs font-semibold ${
|
|
change >= 0 ? "text-red-500" : "text-green-500"
|
|
}`}
|
|
>
|
|
{change >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
|
{change >= 0 ? "+" : ""}
|
|
{fmt(change)}%
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">--</span>
|
|
)}
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{item.fex5 != null ? formatMoney(item.fex5) : "--"}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 分割线 */}
|
|
<hr className="border-border/40" />
|
|
|
|
{/* 领涨股 + 强度 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1 min-w-0">
|
|
<span className="text-[10px] text-muted-foreground shrink-0">领涨</span>
|
|
<span className="text-xs font-medium truncate">{item.securityName || "--"}</span>
|
|
{stockBoard.label && (
|
|
<span
|
|
className={`inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold leading-none shrink-0 ${stockBoard.className}`}
|
|
>
|
|
{stockBoard.label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{item.f3 != null && (
|
|
<span
|
|
className={`text-[10px] tabular-nums ${
|
|
item.f3 >= 0 ? "text-red-500" : "text-green-500"
|
|
}`}
|
|
>
|
|
{item.f3 >= 0 ? "+" : ""}
|
|
{fmt(item.f3)}%
|
|
</span>
|
|
)}
|
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
|
强度 {item.strengthValue ?? "--"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
);
|
|
}
|