重写板块资金流向前端页面,使用 React Query 替代手动状态管理修复数据合并问题
- 使用 @tanstack/react-query 的 useQuery 替代 useState + useEffect + AbortController - queryKey 按 tab 类型 (industry/concept) 隔离数据,根除快速切换导致的数据混用 - 增加空数据提示状态 - 保持原有 UI 结构和交互一致性
This commit is contained in:
+60
-62
@@ -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) => {
|
// 使用 useQuery:queryKey 包含 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]);
|
if (sort === "mainNetInflow") return b.mainNetInflow - a.mainNetInflow;
|
||||||
|
return (b.changePercent ?? 0) - (a.changePercent ?? 0);
|
||||||
const sorted = useMemo(() => {
|
}),
|
||||||
return [...data].sort((a, b) => {
|
[data, sort],
|
||||||
if (sort === "mainNetInflow") return b.mainNetInflow - a.mainNetInflow;
|
);
|
||||||
return (b.changePercent ?? 0) - (a.changePercent ?? 0);
|
|
||||||
});
|
|
||||||
}, [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
|
||||||
isPos ? "text-red-500" : "text-green-500"
|
className={`text-[10px] font-medium tabular-nums w-14 text-right ${
|
||||||
}`}>
|
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
|
||||||
isPositive ? "text-red-500" : "text-green-500"
|
className={`text-xs font-bold tabular-nums ${
|
||||||
}`}>
|
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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user