重写板块资金流向前端页面,使用 React Query 替代手动状态管理修复数据合并问题

- 使用 @tanstack/react-query 的 useQuery 替代 useState + useEffect + AbortController
- queryKey 按 tab 类型 (industry/concept) 隔离数据,根除快速切换导致的数据混用
- 增加空数据提示状态
- 保持原有 UI 结构和交互一致性
This commit is contained in:
Sakurasan
2026-07-13 20:24:25 +08:00
parent 20b2afb0fc
commit 1da4f8f479
+55 -57
View File
@@ -1,14 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useState, useEffect, useMemo, useRef } from "react"; import { useState, useMemo } from "react";
import { useQuery } 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 { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ArrowLeft, TrendingUp, TrendingDown, RefreshCw } from "lucide-react"; import { ArrowLeft, TrendingUp, TrendingDown, RefreshCw } from "lucide-react";
import { Link } from "@tanstack/react-router";
export const Route = createFileRoute("/sectors")({ export const Route = createFileRoute("/sectors")({
component: SectorsPage, component: SectorsPage,
}); });
/* ── 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: "概念板块" },
@@ -16,38 +17,28 @@ const TABS: { key: SectorType; label: string }[] = [
type SortMode = "mainNetInflow" | "changePercent"; type SortMode = "mainNetInflow" | "changePercent";
/* ── 页面组件 ── */
function SectorsPage() { function SectorsPage() {
const [tab, setTab] = useState<SectorType>("industry"); const [tab, setTab] = useState<SectorType>("industry");
const [data, setData] = useState<SectorItem[]>([]);
const [loading, setLoading] = useState(true);
const [sort, setSort] = useState<SortMode>("mainNetInflow"); const [sort, setSort] = useState<SortMode>("mainNetInflow");
const abortRef = useRef<AbortController | null>(null);
const loadData = (t: SectorType) => { // 使用 useQueryqueryKey 包含 tab,切换 tab 时 queryKey 变化
// 取消上次未完成的请求 // React Query 自动隔离 industry / concept 的数据,不会混用
abortRef.current?.abort(); const { data = [], isLoading, isFetching, refetch } = useQuery({
const controller = new AbortController(); queryKey: ["sectors", tab],
abortRef.current = controller; queryFn: ({ signal }) => fetchSectors(tab, signal),
setLoading(true); staleTime: 30_000,
fetchSectors(t, controller.signal).then((items) => { retry: false,
if (!controller.signal.aborted) {
setData(items);
setLoading(false);
}
}); });
};
useEffect(() => { const sorted = useMemo(
loadData(tab); () =>
return () => abortRef.current?.abort(); [...data].sort((a, b) => {
}, [tab]);
const sorted = useMemo(() => {
return [...data].sort((a, b) => {
if (sort === "mainNetInflow") return b.mainNetInflow - a.mainNetInflow; if (sort === "mainNetInflow") return b.mainNetInflow - a.mainNetInflow;
return (b.changePercent ?? 0) - (a.changePercent ?? 0); return (b.changePercent ?? 0) - (a.changePercent ?? 0);
}); }),
}, [data, sort]); [data, sort],
);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@@ -61,27 +52,21 @@ function SectorsPage() {
<h1 className="text-base font-semibold"></h1> <h1 className="text-base font-semibold"></h1>
</div> </div>
<button <button
onClick={() => loadData(tab)} onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button> </button>
</div> </div>
</header> </header>
{/* Tab + Sorting 切换 */} {/* Tab + 排序切换 */}
<div className="max-w-5xl mx-auto px-4 mt-4 flex items-center gap-2"> <div className="max-w-5xl mx-auto px-4 mt-4 flex items-center gap-2">
<div className="flex gap-1 bg-muted rounded-lg p-1 flex-1"> <div className="flex gap-1 bg-muted rounded-lg p-1 flex-1">
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
key={t.key} key={t.key}
onClick={() => { onClick={() => setTab(t.key)}
if (t.key !== tab) {
setData([]);
setLoading(true);
setTab(t.key);
}
}}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${ className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
tab === t.key tab === t.key
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
@@ -93,38 +78,46 @@ function SectorsPage() {
))} ))}
</div> </div>
<div className="flex gap-0.5 text-xs border rounded overflow-hidden shrink-0"> <div className="flex gap-0.5 text-xs border rounded overflow-hidden shrink-0">
<button onClick={() => setSort("mainNetInflow")} <button
onClick={() => setSort("mainNetInflow")}
className={`px-2 py-1 transition-colors ${ className={`px-2 py-1 transition-colors ${
sort === "mainNetInflow" sort === "mainNetInflow"
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
}`} }`}
></button> >
<button onClick={() => setSort("changePercent")}
</button>
<button
onClick={() => setSort("changePercent")}
className={`px-2 py-1 transition-colors ${ className={`px-2 py-1 transition-colors ${
sort === "changePercent" sort === "changePercent"
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
}`} }`}
></button> >
</button>
</div> </div>
</div> </div>
{/* 数据信息 */} {/* 数据概览 */}
<div className="max-w-5xl mx-auto px-4 mt-2"> <div className="max-w-5xl mx-auto px-4 mt-2">
<p className="text-[10px] text-muted-foreground"> <p className="text-[10px] text-muted-foreground">
{sorted.length} · {sort === "mainNetInflow" ? "主力净流入" : "涨跌幅"} {sorted.length} · {sort === "mainNetInflow" ? "主力净流入" : "涨跌幅"}
</p> </p>
</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">
{loading ? ( {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-32" />
))} ))}
</div> </div>
) : sorted.length === 0 ? (
<div className="text-center py-16 text-sm text-muted-foreground"></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) => ( {sorted.map((item) => (
@@ -137,6 +130,7 @@ function SectorsPage() {
); );
} }
/* ── 工具函数 ── */
function formatInflow(val: number | null | undefined): string { function formatInflow(val: number | null | undefined): string {
if (val == null) return "--"; if (val == null) return "--";
const abs = Math.abs(val); const abs = Math.abs(val);
@@ -145,6 +139,7 @@ function formatInflow(val: number | null | undefined): string {
return val.toFixed(0); return val.toFixed(0);
} }
/* ── 资金流向横条 ── */
function FundFlowBar({ value, maxAbs }: { value: number | null | undefined; maxAbs: number }) { function FundFlowBar({ value, maxAbs }: { value: number | null | undefined; maxAbs: number }) {
if (value == null) return null; if (value == null) return null;
const pct = maxAbs > 0 ? (value / maxAbs) * 100 : 0; const pct = maxAbs > 0 ? (value / maxAbs) * 100 : 0;
@@ -159,15 +154,18 @@ function FundFlowBar({ value, maxAbs }: { value: number | null | undefined; maxA
style={{ width: `${Math.min(Math.abs(pct), 100)}%`, marginLeft: isPos ? "50%" : undefined }} style={{ width: `${Math.min(Math.abs(pct), 100)}%`, marginLeft: isPos ? "50%" : undefined }}
/> />
</div> </div>
<span className={`text-[10px] font-medium tabular-nums w-14 text-right ${ <span
className={`text-[10px] font-medium tabular-nums w-14 text-right ${
isPos ? "text-red-500" : "text-green-500" isPos ? "text-red-500" : "text-green-500"
}`}> }`}
>
{formatInflow(value)} {formatInflow(value)}
</span> </span>
</div> </div>
); );
} }
/* ── 单个板块卡片 ── */
function SectorBlock({ item }: { item: SectorItem }) { function SectorBlock({ item }: { item: SectorItem }) {
const inflow = item.mainNetInflow; const inflow = item.mainNetInflow;
const isPositive = inflow >= 0; const isPositive = inflow >= 0;
@@ -178,7 +176,7 @@ function SectorBlock({ item }: { item: SectorItem }) {
Math.abs(item.largeInflow ?? 0), Math.abs(item.largeInflow ?? 0),
Math.abs(item.mediumInflow ?? 0), Math.abs(item.mediumInflow ?? 0),
Math.abs(item.smallInflow ?? 0), Math.abs(item.smallInflow ?? 0),
1 1,
); );
return ( return (
@@ -196,7 +194,7 @@ function SectorBlock({ item }: { item: SectorItem }) {
)} )}
</div> </div>
{/* 涨跌幅 */} {/* 涨跌幅 + 成交额 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{change != null ? ( {change != null ? (
<span <span
@@ -215,23 +213,23 @@ function SectorBlock({ item }: { item: SectorItem }) {
) : ( ) : (
<span className="text-xs text-muted-foreground">--</span> <span className="text-xs text-muted-foreground">--</span>
)} )}
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">{formatInflow(item.turnover)}</span>
{formatInflow(item.turnover)}
</span>
</div> </div>
{/* 主力净流入 */} {/* 主力净流入 + 明细 */}
<div className="pt-1.5 border-t border-border/40"> <div className="pt-1.5 border-t border-border/40">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground"></span> <span className="text-[10px] text-muted-foreground"></span>
<span className={`text-xs font-bold tabular-nums ${ <span
className={`text-xs font-bold tabular-nums ${
isPositive ? "text-red-500" : "text-green-500" isPositive ? "text-red-500" : "text-green-500"
}`}> }`}
{isPositive ? "+" : ""}{formatInflow(inflow)} >
{isPositive ? "+" : ""}
{formatInflow(inflow)}
</span> </span>
</div> </div>
{/* 资金流向明细条 */}
<div className="space-y-0.5"> <div className="space-y-0.5">
<FundFlowBar value={item.superLargeInflow} maxAbs={maxAbs} /> <FundFlowBar value={item.superLargeInflow} maxAbs={maxAbs} />
<FundFlowBar value={item.largeInflow} maxAbs={maxAbs} /> <FundFlowBar value={item.largeInflow} maxAbs={maxAbs} />