Files
auv/src/routes/theme.$code.tsx
T

431 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}