431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
import { createFileRoute, Link } from "@tanstack/react-router";
|
||
import { useEffect, useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import {
|
||
fetchThemeDetail,
|
||
fetchThemeNews,
|
||
fetchThemeQuote,
|
||
fetchThemeStocks,
|
||
type ThemeStock,
|
||
type ThemeNewsItem,
|
||
} 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,
|
||
});
|
||
// 相关新闻:pageNum 偏移分页(东财接口 maxEuTime 是增量游标,翻页靠 pageNum 递增)
|
||
const [newsPage, setNewsPage] = useState(1);
|
||
const [newsItems, setNewsItems] = useState<ThemeNewsItem[]>([]);
|
||
const newsQ = useQuery({
|
||
queryKey: ["themeNews", code, newsPage],
|
||
queryFn: () => fetchThemeNews(code, newsPage, 10),
|
||
staleTime: 60_000,
|
||
retry: false,
|
||
});
|
||
const quoteQ = useQuery({
|
||
queryKey: ["themeQuote", code],
|
||
queryFn: () => fetchThemeQuote(code),
|
||
staleTime: 30_000,
|
||
retry: false,
|
||
});
|
||
|
||
// 分页追加:首页重置列表,翻页拼接
|
||
useEffect(() => {
|
||
if (!newsQ.data?.list) return;
|
||
setNewsItems((prev) => (newsPage === 1 ? newsQ.data!.list : [...prev, ...newsQ.data!.list]));
|
||
}, [newsQ.data, newsPage]);
|
||
|
||
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();
|
||
quoteQ.refetch();
|
||
setNewsPage(1);
|
||
newsQ.refetch();
|
||
};
|
||
|
||
const baseInfo = detail?.baseInfo;
|
||
const hotEvent = detail?.hotEvent;
|
||
|
||
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}
|
||
strength={quoteQ.data?.strengthValue ?? null}
|
||
hotValue={quoteQ.data?.hotValue ?? 0}
|
||
hotValueUpLimit={quoteQ.data?.hotValueUpLimit ?? 0}
|
||
/>
|
||
|
||
{/* ── 相关新闻(分页加载) ── */}
|
||
{newsItems.length > 0 && (
|
||
<NewsList
|
||
items={newsItems}
|
||
total={newsQ.data?.total ?? 0}
|
||
loadingMore={newsQ.isFetching && newsPage > 1}
|
||
onLoadMore={() => setNewsPage((p) => p + 1)}
|
||
/>
|
||
)}
|
||
|
||
{/* ── 相关股票 ── */}
|
||
<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,
|
||
strength,
|
||
hotValue,
|
||
hotValueUpLimit,
|
||
}: {
|
||
f3: number | null | undefined;
|
||
up: number | null | undefined;
|
||
down: number | null | undefined;
|
||
flat: number | null | undefined;
|
||
fex5: number | null | undefined;
|
||
total: number;
|
||
strength: number | null;
|
||
hotValue: number;
|
||
hotValueUpLimit: number;
|
||
}) {
|
||
const isPos = (f3 ?? 0) >= 0;
|
||
const hotPct = hotValueUpLimit > 0 ? Math.min((hotValue / hotValueUpLimit) * 100, 100) : 0;
|
||
const showQuote = strength != null || hotValueUpLimit > 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>
|
||
)}
|
||
{/* 强度 + 热度(来自单题材实时行情接口) */}
|
||
{showQuote && (
|
||
<div className="mt-2 pt-2 border-t border-border/40 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
|
||
{strength != null && (
|
||
<span className="inline-flex items-center gap-1">
|
||
强度
|
||
<b className="font-bold text-foreground tabular-nums">{strength}</b>
|
||
</span>
|
||
)}
|
||
{hotValueUpLimit > 0 && (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<Flame className="h-3 w-3 text-orange-500" />
|
||
<span className="w-16 h-1 rounded-full bg-muted overflow-hidden">
|
||
<span
|
||
className="block h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
|
||
style={{ width: `${Math.max(hotPct, 2)}%` }}
|
||
/>
|
||
</span>
|
||
热度 {hotValue}/{hotValueUpLimit}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
/* ============================================================
|
||
相关新闻(分页加载)
|
||
============================================================ */
|
||
function NewsList({
|
||
items,
|
||
total,
|
||
loadingMore,
|
||
onLoadMore,
|
||
}: {
|
||
items: ThemeNewsItem[];
|
||
total: number;
|
||
loadingMore: boolean;
|
||
onLoadMore: () => void;
|
||
}) {
|
||
const hasMore = items.length < total;
|
||
|
||
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">共 {total} 条</span>
|
||
</div>
|
||
<div className="space-y-2.5">
|
||
{items.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.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""}
|
||
{n.commentCount > 0 && <span className="ml-1">· {n.commentCount} 评论</span>}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{hasMore && (
|
||
<button
|
||
onClick={onLoadMore}
|
||
disabled={loadingMore}
|
||
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5 disabled:opacity-50"
|
||
>
|
||
{loadingMore ? "加载中…" : "加载更多"}
|
||
{!loadingMore && <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) >= 0;
|
||
const reasons = stock.keywordList ?? [];
|
||
|
||
// 换手率:接口返回放大 100 倍的值(如 3733 = 37.33%)
|
||
const turnoverRate = stock.f8 != null ? (stock.f8 > 100 ? stock.f8 / 100 : stock.f8) : null;
|
||
|
||
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 != null ? 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 != null ? 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 != null ? 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>
|
||
);
|
||
}
|