重构板块资金流向页面:三层数据分离(缓存→排序→展示),切换 Tab 清空缓存,排序改为资金/涨幅,新增 error/empty 状态处理

- 缓存数据、排序数据、展示数据三层分离架构
- 切换板块时 queryClient.removeQueries 清空全部缓存
- 排序维度改为 mainNetInflow(资金)和 mainNetInflowPercent(涨幅)
- 卡片展示主力净流入金额 + 占比
- 新增加载骨架屏、请求失败重试、空数据提示等状态
- 资金流向明细条(超大单/大单/中单/小单)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-07-13 23:46:00 +08:00
co-authored by Claude
parent 22692be45e
commit bd1df35456
+220 -122
View File
@@ -1,29 +1,50 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchSectors, type SectorItem, type SectorType } from "@/lib/stock-api"; import { fetchSectors, type SectorItem, type SectorType } from "@/lib/stock-api";
import { formatMoney } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ArrowLeft, RefreshCw, ArrowDown, ArrowUp, TrendingUp, TrendingDown } from "lucide-react"; import {
ArrowLeft,
RefreshCw,
ArrowDown,
ArrowUp,
TrendingUp,
TrendingDown,
} from "lucide-react";
export const Route = createFileRoute("/sectors")({ export const Route = createFileRoute("/sectors")({
component: SectorsPage, component: SectorsPage,
}); });
/* ── Tabs:行业 / 概念 ── */ /* ============================================================
Tab 定义:行业 / 概念
============================================================ */
const TABS: { key: SectorType; label: string }[] = [ const TABS: { key: SectorType; label: string }[] = [
{ key: "industry", label: "行业" }, { key: "industry", label: "行业" },
{ key: "concept", label: "概念" }, { key: "concept", label: "概念" },
]; ];
/* ── 排序维度 ── */ /* ============================================================
type SortKey = "mainNetInflow" | "changePercent"; 排序维度
============================================================ */
type SortKey = "mainNetInflow" | "mainNetInflowPercent";
const SORT_LABEL: Record<SortKey, string> = {
mainNetInflow: "资金",
mainNetInflowPercent: "涨幅",
};
/* ============================================================
页面组件
============================================================ */
function SectorsPage() { function SectorsPage() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<SectorType>("industry"); const [tab, setTab] = useState<SectorType>("industry");
const [sortKey, setSortKey] = useState<SortKey>("mainNetInflow"); const [sortKey, setSortKey] = useState<SortKey>("mainNetInflow");
const [asc, setAsc] = useState(true); const [asc, setAsc] = useState(false); // 默认降序
// 行业 / 概念 各自独立 Query数据完全隔离,杜绝来回切换时的合并 // ── 行业 / 概念各自独立 Query缓存完全隔离 ──
const industryQ = useQuery({ const industryQ = useQuery({
queryKey: ["sectors", "industry"], queryKey: ["sectors", "industry"],
queryFn: ({ signal }) => fetchSectors("industry", signal), queryFn: ({ signal }) => fetchSectors("industry", signal),
@@ -37,35 +58,57 @@ function SectorsPage() {
retry: false, retry: false,
}); });
const { data = [], isLoading, isFetching, refetch } = // 当前激活的 tab 查询
tab === "industry" ? industryQ : conceptQ; const activeQuery = tab === "industry" ? industryQ : conceptQ;
const { isLoading, isFetching, isError, refetch } = activeQuery;
// 切换 tab 时强制重新请求,绕过 React Query 缓存和上游反代缓存,确保拿到对应类型的数据 /* ═══════════════════════════════════════════════════════
const handleTab = (t: SectorType) => { 三层数据分离:缓存 → 排序 → 展示
setTab(t); ═══════════════════════════════════════════════════════ */
(t === "industry" ? industryQ : conceptQ).refetch();
};
const sorted = useMemo(() => { // ① 缓存数据 — React Query 从后端拿到的原始数据
const cachedData: SectorItem[] = activeQuery.data ?? [];
// ② 排序数据 — 按当前排序规则在内存中重排
const sortedData = useMemo<SectorItem[]>(() => {
const dir = asc ? 1 : -1; const dir = asc ? 1 : -1;
return [...data].sort((a, b) => { return [...cachedData].sort((a, b) => {
const av = sortKey === "mainNetInflow" ? a.mainNetInflow : a.changePercent ?? -Infinity; const av =
const bv = sortKey === "mainNetInflow" ? b.mainNetInflow : b.changePercent ?? -Infinity; sortKey === "mainNetInflow"
? a.mainNetInflow
: (a.mainNetInflowPercent ?? -Infinity);
const bv =
sortKey === "mainNetInflow"
? b.mainNetInflow
: (b.mainNetInflowPercent ?? -Infinity);
return (bv - av) * dir; return (bv - av) * dir;
}); });
}, [data, sortKey, asc]); }, [cachedData, sortKey, asc]);
// ③ 展示数据 — 最终渲染的数据集(当前即排序数据,后续可加分页截断)
const displayData = sortedData;
// ── 切换板块:清空全部缓存 + 重新获取 ──
const handleTab = (t: SectorType) => {
if (t === tab) return;
setTab(t);
// 移除所有板块缓存,切换后对应的 useQuery 会自动 refetch
queryClient.removeQueries({ queryKey: ["sectors"] });
};
// ── 切换排序 ──
const toggleSort = (key: SortKey) => { const toggleSort = (key: SortKey) => {
if (key === sortKey) setAsc((v) => !v); if (key === sortKey) {
else { setAsc((v) => !v);
} else {
setSortKey(key); setSortKey(key);
setAsc(false); setAsc(false); // 切新维度默认降序
} }
}; };
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* 顶栏 */} {/* ── 顶栏 ── */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b"> <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="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -77,13 +120,16 @@ function SectorsPage() {
<button <button
onClick={() => refetch()} onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新"
> >
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} /> <RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
</button> </button>
</div> </div>
</header> </header>
{/* Tab 切换:行业 / 概念 */} {/* ── Tab 切换 ── */}
<div className="max-w-5xl mx-auto px-4 mt-4"> <div className="max-w-5xl mx-auto px-4 mt-4">
<div className="flex gap-1 bg-muted rounded-lg p-1"> <div className="flex gap-1 bg-muted rounded-lg p-1">
{TABS.map((t) => ( {TABS.map((t) => (
@@ -102,31 +148,65 @@ function SectorsPage() {
</div> </div>
</div> </div>
{/* 排序切换 */} {/* ── 排序切换 + 统计 ── */}
<div className="max-w-5xl mx-auto px-4 mt-3 flex items-center justify-between"> <div className="max-w-5xl mx-auto px-4 mt-3 flex items-center justify-between">
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{sorted.length} · {tab === "industry" ? "行业" : "概念"} {cachedData.length}
{isFetching && (
<span className="ml-1 text-[10px] text-muted-foreground/60">
·
</span>
)}
</p> </p>
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden"> <div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
<SortButton label="资金流入" active={sortKey === "mainNetInflow"} asc={asc} onClick={() => toggleSort("mainNetInflow")} /> {(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
<SortButton label="涨幅%" active={sortKey === "changePercent"} asc={asc} onClick={() => toggleSort("changePercent")} /> <button
key={key}
onClick={() => toggleSort(key)}
className={`px-2.5 py-1 flex items-center gap-0.5 transition-colors ${
sortKey === key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{SORT_LABEL[key]}
{sortKey === key &&
(asc ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</button>
))}
</div> </div>
</div> </div>
{/* 卡片列表 */} {/* ── 内容区 ── */}
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8"> <div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
{/* 加载骨架 */}
{isLoading ? ( {isLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{Array.from({ length: 20 }).map((_, i) => ( {Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-xl bg-muted h-32" /> <div key={i} className="animate-pulse rounded-xl bg-muted h-40" />
))} ))}
</div> </div>
) : sorted.length === 0 ? ( ) : isError ? (
<div className="text-center py-16 text-sm text-muted-foreground"></div> /* 请求失败 */
<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>
) : cachedData.length === 0 ? (
/* 数据为空 */
<div className="text-center py-20 text-sm text-muted-foreground">
{tab === "industry" ? "行业" : "概念"}
</div>
) : ( ) : (
/* 板块卡片网格 */
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{sorted.map((item) => ( {displayData.map((item) => (
<SectorBlock key={item.code} item={item} /> <SectorCard key={item.code} item={item} />
))} ))}
</div> </div>
)} )}
@@ -135,83 +215,27 @@ function SectorsPage() {
); );
} }
function SortButton({ /* ============================================================
label, 数值格式化
active, ============================================================ */
asc, function fmt(val: number | null | undefined, digits = 2): string {
onClick,
}: {
label: string;
active: boolean;
asc: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`px-2.5 py-1 flex items-center gap-0.5 transition-colors ${
active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{label}
{active && (asc ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</button>
);
}
/* ── 数值格式化 ── */
function formatInflow(val: number | null | undefined): string {
if (val == null) return "--"; if (val == null) return "--";
const abs = Math.abs(val); return val.toFixed(digits);
if (abs >= 1e8) return (val / 1e8).toFixed(2) + "亿";
if (abs >= 1e4) return (val / 1e4).toFixed(0) + "万";
return val.toFixed(0);
} }
/* ── 资金流向横条 ── */ /* ============================================================
function FundFlowBar({ value, maxAbs }: { value: number | null | undefined; maxAbs: number }) { 板块卡片
if (value == null) return null; ============================================================ */
const pct = maxAbs > 0 ? (value / maxAbs) * 100 : 0; function SectorCard({ item }: { item: SectorItem }) {
const isPos = value >= 0;
return (
<div className="flex items-center gap-1">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
isPos ? "bg-red-500/60" : "bg-green-500/60"
}`}
style={{ width: `${Math.min(Math.abs(pct), 100)}%`, marginLeft: isPos ? "50%" : undefined }}
/>
</div>
<span
className={`text-[10px] font-medium tabular-nums w-14 text-right ${
isPos ? "text-red-500" : "text-green-500"
}`}
>
{formatInflow(value)}
</span>
</div>
);
}
/* ── 单个板块卡片 ── */
function SectorBlock({ item }: { item: SectorItem }) {
const inflow = item.mainNetInflow;
const isPositive = inflow >= 0;
const change = item.changePercent; const change = item.changePercent;
const maxAbs = Math.max( const inflow = item.mainNetInflow;
Math.abs(item.mainNetInflow), const inflowIsPos = inflow >= 0;
Math.abs(item.superLargeInflow ?? 0), const inflowPct = item.mainNetInflowPercent;
Math.abs(item.largeInflow ?? 0),
Math.abs(item.mediumInflow ?? 0),
Math.abs(item.smallInflow ?? 0),
1,
);
return ( return (
<Card className="rounded-xl hover:shadow-md transition-shadow"> <Card className="rounded-xl hover:shadow-md transition-shadow">
<CardContent className="p-3 space-y-2"> <CardContent className="p-3 space-y-1.5">
{/* 板块名称 */} {/* 板块名称 + 代码 */}
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
<p className="text-sm font-medium truncate" title={item.name}> <p className="text-sm font-medium truncate" title={item.name}>
{item.name} {item.name}
@@ -231,38 +255,112 @@ function SectorBlock({ item }: { item: SectorItem }) {
change >= 0 ? "text-red-500" : "text-green-500" change >= 0 ? "text-red-500" : "text-green-500"
}`} }`}
> >
{change >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />} {change >= 0 ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{change >= 0 ? "+" : ""} {change >= 0 ? "+" : ""}
{change.toFixed(2)}% {fmt(change)}%
</span> </span>
) : ( ) : (
<span className="text-xs text-muted-foreground">--</span> <span className="text-xs text-muted-foreground">--</span>
)} )}
<span className="text-[10px] text-muted-foreground">{formatInflow(item.turnover)}</span> <span className="text-[10px] text-muted-foreground">
{formatMoney(item.turnover)}
</span>
</div> </div>
{/* 主力净流入 + 明细 */} {/* 分割线 */}
<div className="pt-1.5 border-t border-border/40"> <hr className="border-border/40" />
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground"></span> {/* 主力净流入金额 + 占比 */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground"></span>
<div className="flex items-center gap-2">
<span <span
className={`text-xs font-bold tabular-nums ${ className={`text-xs font-bold tabular-nums ${
isPositive ? "text-red-500" : "text-green-500" inflowIsPos ? "text-red-500" : "text-green-500"
}`} }`}
> >
{isPositive ? "+" : ""} {inflow >= 0 ? "+" : ""}
{formatInflow(inflow)} {formatMoney(inflow)}
</span> </span>
</div> {inflowPct != null && (
<span
<div className="space-y-0.5"> className={`text-[10px] tabular-nums ${
<FundFlowBar value={item.superLargeInflow} maxAbs={maxAbs} /> inflowIsPos ? "text-red-500/70" : "text-green-500/70"
<FundFlowBar value={item.largeInflow} maxAbs={maxAbs} /> }`}
<FundFlowBar value={item.mediumInflow} maxAbs={maxAbs} /> >
<FundFlowBar value={item.smallInflow} maxAbs={maxAbs} /> {inflow >= 0 ? "+" : ""}
{fmt(inflowPct)}%
</span>
)}
</div> </div>
</div> </div>
{/* 资金流向明细条 */}
<FundFlowBreakdown item={item} />
</CardContent> </CardContent>
</Card> </Card>
); );
} }
/* ============================================================
资金流向明细 — 超大单 / 大单 / 中单 / 小单
============================================================ */
const FLOW_LABELS = [
{ key: "superLargeInflow" as const, label: "超大单" },
{ key: "largeInflow" as const, label: "大单" },
{ key: "mediumInflow" as const, label: "中单" },
{ key: "smallInflow" as const, label: "小单" },
];
function FundFlowBreakdown({ item }: { item: SectorItem }) {
// 取所有流量的最大绝对值做归一化
const maxAbs = Math.max(
Math.abs(item.mainNetInflow),
Math.abs(item.superLargeInflow ?? 0),
Math.abs(item.largeInflow ?? 0),
Math.abs(item.mediumInflow ?? 0),
Math.abs(item.smallInflow ?? 0),
1,
);
return (
<div className="space-y-0.5">
{FLOW_LABELS.map((f) => {
const val = item[f.key];
if (val == null) return null;
const pct = maxAbs > 0 ? (Math.abs(val) / maxAbs) * 100 : 0;
const isPos = val >= 0;
return (
<div key={f.key} className="flex items-center gap-1.5">
<span className="text-[9px] text-muted-foreground w-6 shrink-0 text-right">
{f.label}
</span>
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden relative">
<div
className={`h-full rounded-full transition-all ${
isPos ? "bg-red-500/60 ml-1/2" : "bg-green-500/60"
}`}
style={{
width: `${Math.min(pct, 100)}%`,
marginLeft: isPos ? "50%" : undefined,
marginRight: isPos ? undefined : `${100 - Math.min(pct, 100)}%`,
}}
/>
</div>
<span
className={`text-[9px] font-medium tabular-nums w-14 text-right shrink-0 ${
isPos ? "text-red-500" : "text-green-500"
}`}
>
{formatMoney(val)}
</span>
</div>
);
})}
</div>
);
}