feat: 新增题材页面(列表 + 详情)

- 主页增加「题材热点」入口,跳转题材列表页
- 题材列表页:展示全部题材,支持按涨幅/强度/热度/成交额排序
- 题材详情页:简介、热点事件、相关新闻、板块涨跌统计、全部相关股票(含入选理由默认展开)
- 后端逆向封装东方财富题材接口 getThemeList/getDetail/getStockList,含交易时段感知缓存

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-06 17:22:04 +08:00
co-authored by Claude
parent b107a12798
commit 45836f4a30
8 changed files with 1100 additions and 11 deletions
+160
View File
@@ -0,0 +1,160 @@
// 题材数据获取工具:通过 Python 后端代理调用东方财富题材接口
import { getApiBaseUrl } from "@/lib/api-client";
/* ── 题材列表 ── */
export interface ThemeItem {
themeCode: string;
themeName: string;
securityName: string; // 领涨股名称
securityCode: string; // 领涨股代码
codeWithSuffix: string;
hotRank: number; // 热度排名
f3: number | null; // 领涨股涨幅
bf3: number | null; // 题材涨幅
hotValue: number; // 热度值
hotValueUpLimit: number; // 热度上限
strengthValue: number | null; // 强度值
fex5: number | null; // 成交额
fex3: number | null;
label: string | null; // 标签(如"超级爆点"
}
export type ThemeSortField = 1 | 3 | 4 | 5; // 1=涨幅 3=强度 4=热度排名 5=成交额
export interface ThemeListResponse {
data: ThemeItem[];
count: number;
sort_field: number;
asc: boolean;
}
/**
* 获取全部题材列表
* @param sortField 1=涨幅 3=强度 4=热度排名 5=成交额
* @param asc true=升序
*/
export async function fetchThemes(sortField: ThemeSortField = 1, asc: boolean = false): Promise<ThemeItem[]> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes?sort_field=${sortField}&asc=${asc}`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return [];
const result: ThemeListResponse = await resp.json();
return result.data || [];
} catch (err) {
console.error("[theme-api] 获取题材列表失败:", err);
return [];
}
}
/* ── 题材详情 ── */
export interface ThemeHotEvent {
newsTitle: string | null;
newsSummary: string | null;
newsMediaName: string | null;
newsPublishTimeFormat: string | null;
newsCode: string | null;
}
export interface ThemeNews {
newsCode: string;
newsTitle: string;
newsMediaName: string;
newsPublishTime: number | null;
}
export interface ThemeBaseInfo {
themeCode: string;
themeName: string;
introduction: string;
explainImgUrl: string | null;
themeLevel: number;
isShowRank: number;
}
export interface ThemeDetail {
baseInfo: ThemeBaseInfo;
hotEvent: ThemeHotEvent | null;
eventHistory: ThemeNews[];
topicId: string | null;
}
/**
* 获取题材详情(简介 + 热点事件 + 相关新闻)
*/
export async function fetchThemeDetail(themeCode: string): Promise<ThemeDetail | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes/${themeCode}/detail`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
const result = await resp.json();
return result.data || null;
} catch (err) {
console.error("[theme-api] 获取题材详情失败:", err);
return null;
}
}
/* ── 题材相关股票 ── */
export interface ThemeStockKeyword {
keywordCode: string;
keyword: string;
introduction: string;
}
export interface ThemeStock {
securityName: string;
securityCode: string;
codeSuffix: string;
f2: number; // 现价
f3: number; // 涨跌幅%
f5: number; // 成交量
f6: number; // 成交额
f8: number; // 换手率%
f20: number; // 总市值
f21: number; // 流通市值
f62: number; // 主力净流入
f100: string; // 所属行业
f265: string; // 板块代码
label: string | null; // 涨停标签
rank: number;
dragonStockLabel: number;
keywordList: ThemeStockKeyword[]; // 入选理由
}
export interface ThemeStatistic {
f3: number | null; // 板块涨幅
f104: number | null; // 上涨家数
f105: number | null; // 下跌家数
f106: number | null; // 平盘家数
fex5: number | null; // 板块成交额
}
export interface ThemeStocksResponse {
data: ThemeStock[];
statistic: ThemeStatistic;
total: number;
}
/**
* 获取题材下全部相关股票
*/
export async function fetchThemeStocks(themeCode: string): Promise<ThemeStocksResponse | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes/${themeCode}/stocks`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
return await resp.json();
} catch (err) {
console.error("[theme-api] 获取题材股票失败:", err);
return null;
}
}
+58 -3
View File
@@ -9,11 +9,18 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as ThemesRouteImport } from './routes/themes'
import { Route as SectorsRouteImport } from './routes/sectors'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
import { Route as StockCodeRouteImport } from './routes/stock.$code'
import { Route as ShareCodeRouteImport } from './routes/share.$code'
const ThemesRoute = ThemesRouteImport.update({
id: '/themes',
path: '/themes',
getParentRoute: () => rootRouteImport,
} as any)
const SectorsRoute = SectorsRouteImport.update({
id: '/sectors',
path: '/sectors',
@@ -24,6 +31,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ThemeCodeRoute = ThemeCodeRouteImport.update({
id: '/theme/$code',
path: '/theme/$code',
getParentRoute: () => rootRouteImport,
} as any)
const StockCodeRoute = StockCodeRouteImport.update({
id: '/stock/$code',
path: '/stock/$code',
@@ -38,39 +50,73 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/sectors' | '/share/$code' | '/stock/$code'
fullPaths:
| '/'
| '/sectors'
| '/themes'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/sectors' | '/share/$code' | '/stock/$code'
id: '__root__' | '/' | '/sectors' | '/share/$code' | '/stock/$code'
to:
| '/'
| '/sectors'
| '/themes'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
id:
| '__root__'
| '/'
| '/sectors'
| '/themes'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SectorsRoute: typeof SectorsRoute
ThemesRoute: typeof ThemesRoute
ShareCodeRoute: typeof ShareCodeRoute
StockCodeRoute: typeof StockCodeRoute
ThemeCodeRoute: typeof ThemeCodeRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/themes': {
id: '/themes'
path: '/themes'
fullPath: '/themes'
preLoaderRoute: typeof ThemesRouteImport
parentRoute: typeof rootRouteImport
}
'/sectors': {
id: '/sectors'
path: '/sectors'
@@ -85,6 +131,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/theme/$code': {
id: '/theme/$code'
path: '/theme/$code'
fullPath: '/theme/$code'
preLoaderRoute: typeof ThemeCodeRouteImport
parentRoute: typeof rootRouteImport
}
'/stock/$code': {
id: '/stock/$code'
path: '/stock/$code'
@@ -105,8 +158,10 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SectorsRoute: SectorsRoute,
ThemesRoute: ThemesRoute,
ShareCodeRoute: ShareCodeRoute,
StockCodeRoute: StockCodeRoute,
ThemeCodeRoute: ThemeCodeRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+15 -7
View File
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2 } from "lucide-react";
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/")({
@@ -211,12 +211,20 @@ function Index() {
A股走势追踪
</h1>
<p className="text-sm md:text-base text-muted-foreground"></p>
<Link to="/sectors">
<Button variant="outline" size="sm" className="mt-3 gap-1.5 text-xs">
<TrendingUp className="h-3.5 w-3.5" />
</Button>
</Link>
<div className="mt-3 flex items-center justify-center gap-2">
<Link to="/sectors">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<TrendingUp className="h-3.5 w-3.5" />
</Button>
</Link>
<Link to="/themes">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<Flame className="h-3.5 w-3.5" />
</Button>
</Link>
</div>
</div>
<Card className="mb-6 md:mb-8 shadow-lg">
+355
View File
@@ -0,0 +1,355 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchThemeDetail, fetchThemeStocks, type ThemeStock } 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,
TrendingUp,
TrendingDown,
Flame,
Newspaper,
ChevronDown,
ChevronUp,
Info,
} from "lucide-react";
export const Route = createFileRoute("/theme/$code")({
component: ThemeDetailPage,
});
function ThemeDetailPage() {
const { code } = Route.useParams();
const detailQ = useQuery({
queryKey: ["themeDetail", code],
queryFn: () => fetchThemeDetail(code),
staleTime: 60_000,
retry: false,
});
const stocksQ = useQuery({
queryKey: ["themeStocks", code],
queryFn: () => fetchThemeStocks(code),
staleTime: 30_000,
retry: false,
});
const isLoading = detailQ.isLoading || stocksQ.isLoading;
const isError = detailQ.isError || stocksQ.isError;
const isFetching = detailQ.isFetching || stocksQ.isFetching;
const detail = detailQ.data;
const stocks = stocksQ.data?.data ?? [];
const statistic = stocksQ.data?.statistic;
const total = stocksQ.data?.total ?? 0;
const refresh = () => {
detailQ.refetch();
stocksQ.refetch();
};
const baseInfo = detail?.baseInfo;
const hotEvent = detail?.hotEvent;
const eventHistory = detail?.eventHistory ?? [];
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-3xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<Link to="/themes" className="hover:opacity-70 transition-opacity shrink-0">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold truncate">{baseInfo?.themeName ?? "题材详情"}</h1>
</div>
<button
onClick={refresh}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button>
</div>
</header>
<div className="max-w-3xl mx-auto px-4 py-4 pb-10 space-y-4">
{isLoading ? (
<div className="space-y-4">
<div className="animate-pulse rounded-xl bg-muted h-32" />
<div className="animate-pulse rounded-xl bg-muted h-24" />
<div className="animate-pulse rounded-xl bg-muted h-64" />
</div>
) : isError ? (
<div className="flex flex-col items-center gap-3 py-20">
<p className="text-sm text-muted-foreground"></p>
<button onClick={refresh} className="text-xs text-primary hover:underline">
</button>
</div>
) : (
<>
{/* ── 题材简介 ── */}
{baseInfo?.introduction && (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<Info className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold"></h2>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{baseInfo.introduction}
</p>
</CardContent>
</Card>
)}
{/* ── 热点事件 ── */}
{hotEvent?.newsTitle && (
<Card className="border-orange-500/30">
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<Flame className="h-4 w-4 text-orange-500" />
<h2 className="text-sm font-semibold"></h2>
{hotEvent.newsMediaName && (
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
{hotEvent.newsMediaName}
{hotEvent.newsPublishTimeFormat ? ` · ${hotEvent.newsPublishTimeFormat}` : ""}
</span>
)}
</div>
<p className="text-sm font-medium leading-snug">{hotEvent.newsTitle}</p>
{hotEvent.newsSummary && (
<p className="text-xs text-muted-foreground leading-relaxed mt-1.5 line-clamp-3">
{hotEvent.newsSummary}
</p>
)}
</CardContent>
</Card>
)}
{/* ── 板块统计 ── */}
<StatBar
f3={statistic?.f3}
up={statistic?.f104}
down={statistic?.f105}
flat={statistic?.f106}
fex5={statistic?.fex5}
total={total}
/>
{/* ── 相关新闻(可折叠) ── */}
{eventHistory.length > 0 && <NewsList items={eventHistory} />}
{/* ── 相关股票 ── */}
<div>
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-sm font-semibold"></h2>
<span className="text-[10px] text-muted-foreground"> {total} </span>
</div>
{stocks.length === 0 ? (
<Card>
<CardContent className="p-6 text-center text-sm text-muted-foreground">
</CardContent>
</Card>
) : (
<div className="space-y-2">
{stocks.map((s) => (
<ThemeStockRow key={s.securityCode} stock={s} />
))}
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
/* ============================================================
板块统计条
============================================================ */
function StatBar({
f3,
up,
down,
flat,
fex5,
total,
}: {
f3: number | null | undefined;
up: number | null | undefined;
down: number | null | undefined;
flat: number | null | undefined;
fex5: number | null | undefined;
total: number;
}) {
const isPos = (f3 ?? 0) >= 0;
return (
<Card>
<CardContent className="p-3">
<div className="grid grid-cols-4 divide-x divide-border/50 text-center">
<div>
<p className="text-[10px] text-muted-foreground"></p>
<p className={`text-sm font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
{f3 != null ? `${isPos ? "+" : ""}${f3.toFixed(2)}%` : "--"}
</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground"></p>
<p className="text-sm font-bold tabular-nums text-red-500">{up ?? "--"}</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground"></p>
<p className="text-sm font-bold tabular-nums text-green-500">{down ?? "--"}</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground"></p>
<p className="text-xs font-semibold tabular-nums">{fex5 != null ? formatMoney(fex5) : "--"}</p>
</div>
</div>
{(flat != null && flat > 0) && (
<p className="text-[10px] text-muted-foreground text-center mt-1.5">
{flat}
</p>
)}
</CardContent>
</Card>
);
}
/* ============================================================
相关新闻(可折叠)
============================================================ */
function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string; newsPublishTime: number | null }[] }) {
const [expanded, setExpanded] = useState(false);
const shown = expanded ? items : items.slice(0, 2);
const fmtTime = (ts: number | null) => {
if (!ts) return "";
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<Newspaper className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold"></h2>
<span className="text-[10px] text-muted-foreground ml-auto">{items.length} </span>
</div>
<div className="space-y-2.5">
{shown.map((n, idx) => (
<div key={idx} className="space-y-0.5">
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
<p className="text-[10px] text-muted-foreground">
{n.newsMediaName}
{n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""}
</p>
</div>
))}
</div>
{items.length > 2 && (
<button
onClick={() => setExpanded((v) => !v)}
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5"
>
{expanded ? "收起" : `展开全部 ${items.length}`}
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
)}
</CardContent>
</Card>
);
}
/* ============================================================
相关股票行
============================================================ */
function ThemeStockRow({ stock }: { stock: ThemeStock }) {
const [showReason, setShowReason] = useState(true); // 入选理由默认展开
const board = getStockBoard(stock.securityCode);
const isPos = stock.f3 >= 0;
const reasons = stock.keywordList ?? [];
// 换手率:接口返回放大 100 倍的值(如 3733 = 37.33%
const turnoverRate = stock.f8 > 100 ? stock.f8 / 100 : stock.f8;
return (
<Link to="/stock/$code" params={{ code: stock.securityCode }} className="block">
<Card className="rounded-xl hover:shadow-md transition-shadow">
<CardContent className="p-3 space-y-1.5">
{/* 名称 + 现价 + 涨幅 */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<p className="text-sm font-medium truncate">{stock.securityName}</p>
{board.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 ${board.className}`}
>
{board.label}
</span>
)}
{stock.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">
{stock.label}
</span>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="text-right">
<p className="text-[10px] text-muted-foreground"></p>
<p className="text-sm font-semibold tabular-nums">{stock.f2.toFixed(2)}</p>
</div>
<div className="text-right min-w-[56px]">
<p className="text-[10px] text-muted-foreground"></p>
<p className={`text-sm font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
{isPos ? "+" : ""}
{stock.f3.toFixed(2)}%
</p>
</div>
</div>
</div>
{/* 行业 + 换手 + 主力 + 成交额 */}
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
{stock.f100 && (
<span className="truncate bg-muted rounded px-1.5 py-0.5 text-[10px]">{stock.f100}</span>
)}
<span className="shrink-0 tabular-nums"> {turnoverRate.toFixed(2)}%</span>
<span className="shrink-0 tabular-nums"> {formatMoney(stock.f62)}</span>
<span className="shrink-0 tabular-nums ml-auto"> {formatMoney(stock.f6)}</span>
</div>
{/* 入选理由 */}
{reasons.length > 0 && (
<div className="border-t border-border/40 pt-1.5">
<button
onClick={(e) => {
e.preventDefault();
setShowReason((v) => !v);
}}
className="text-[10px] text-primary hover:underline inline-flex items-center gap-0.5"
>
{showReason ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
{showReason && (
<p className="text-[11px] text-muted-foreground leading-relaxed mt-1">
{reasons.map((r) => r.introduction).filter(Boolean).join(" ")}
</p>
)}
</div>
)}
</CardContent>
</Card>
</Link>
);
}
+234
View File
@@ -0,0 +1,234 @@
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,
} 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>
<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 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>
);
}