重构板块资金流向页面:三层数据分离(缓存→排序→展示),切换 Tab 清空缓存,排序改为资金/涨幅,新增 error/empty 状态处理
- 缓存数据、排序数据、展示数据三层分离架构 - 切换板块时 queryClient.removeQueries 清空全部缓存 - 排序维度改为 mainNetInflow(资金)和 mainNetInflowPercent(涨幅) - 卡片展示主力净流入金额 + 占比 - 新增加载骨架屏、请求失败重试、空数据提示等状态 - 资金流向明细条(超大单/大单/中单/小单) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+224
-126
@@ -1,29 +1,50 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
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 { formatMoney } from "@/lib/utils";
|
||||
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")({
|
||||
component: SectorsPage,
|
||||
});
|
||||
|
||||
/* ── Tabs:行业 / 概念 ── */
|
||||
/* ============================================================
|
||||
Tab 定义:行业 / 概念
|
||||
============================================================ */
|
||||
const TABS: { key: SectorType; label: string }[] = [
|
||||
{ key: "industry", label: "行业" },
|
||||
{ key: "concept", label: "概念" },
|
||||
];
|
||||
|
||||
/* ── 排序维度 ── */
|
||||
type SortKey = "mainNetInflow" | "changePercent";
|
||||
/* ============================================================
|
||||
排序维度
|
||||
============================================================ */
|
||||
type SortKey = "mainNetInflow" | "mainNetInflowPercent";
|
||||
|
||||
const SORT_LABEL: Record<SortKey, string> = {
|
||||
mainNetInflow: "资金",
|
||||
mainNetInflowPercent: "涨幅",
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
页面组件
|
||||
============================================================ */
|
||||
function SectorsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState<SectorType>("industry");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("mainNetInflow");
|
||||
const [asc, setAsc] = useState(true);
|
||||
const [asc, setAsc] = useState(false); // 默认降序
|
||||
|
||||
// 行业 / 概念 各自独立的 Query,数据完全隔离,杜绝来回切换时的合并
|
||||
// ── 行业 / 概念各自独立 Query,缓存完全隔离 ──
|
||||
const industryQ = useQuery({
|
||||
queryKey: ["sectors", "industry"],
|
||||
queryFn: ({ signal }) => fetchSectors("industry", signal),
|
||||
@@ -37,35 +58,57 @@ function SectorsPage() {
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const { data = [], isLoading, isFetching, refetch } =
|
||||
tab === "industry" ? industryQ : conceptQ;
|
||||
// 当前激活的 tab 查询
|
||||
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;
|
||||
return [...data].sort((a, b) => {
|
||||
const av = sortKey === "mainNetInflow" ? a.mainNetInflow : a.changePercent ?? -Infinity;
|
||||
const bv = sortKey === "mainNetInflow" ? b.mainNetInflow : b.changePercent ?? -Infinity;
|
||||
return [...cachedData].sort((a, b) => {
|
||||
const av =
|
||||
sortKey === "mainNetInflow"
|
||||
? a.mainNetInflow
|
||||
: (a.mainNetInflowPercent ?? -Infinity);
|
||||
const bv =
|
||||
sortKey === "mainNetInflow"
|
||||
? b.mainNetInflow
|
||||
: (b.mainNetInflowPercent ?? -Infinity);
|
||||
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) => {
|
||||
if (key === sortKey) setAsc((v) => !v);
|
||||
else {
|
||||
if (key === sortKey) {
|
||||
setAsc((v) => !v);
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setAsc(false);
|
||||
setAsc(false); // 切新维度默认降序
|
||||
}
|
||||
};
|
||||
|
||||
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-5xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -77,13 +120,16 @@ function SectorsPage() {
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tab 切换:行业 / 概念 */}
|
||||
{/* ── Tab 切换 ── */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-4">
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-1">
|
||||
{TABS.map((t) => (
|
||||
@@ -102,31 +148,65 @@ function SectorsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 排序切换 */}
|
||||
{/* ── 排序切换 + 统计 ── */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 flex items-center justify-between">
|
||||
<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>
|
||||
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
|
||||
<SortButton label="资金流入" active={sortKey === "mainNetInflow"} asc={asc} onClick={() => toggleSort("mainNetInflow")} />
|
||||
<SortButton label="涨幅%" active={sortKey === "changePercent"} asc={asc} onClick={() => toggleSort("changePercent")} />
|
||||
{(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
|
||||
<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 className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||
{/* 加载骨架 */}
|
||||
{isLoading ? (
|
||||
<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) => (
|
||||
<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>
|
||||
) : sorted.length === 0 ? (
|
||||
<div className="text-center py-16 text-sm text-muted-foreground">暂无数据</div>
|
||||
) : isError ? (
|
||||
/* 请求失败 */
|
||||
<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">
|
||||
{sorted.map((item) => (
|
||||
<SectorBlock key={item.code} item={item} />
|
||||
{displayData.map((item) => (
|
||||
<SectorCard key={item.code} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -135,83 +215,27 @@ function SectorsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function SortButton({
|
||||
label,
|
||||
active,
|
||||
asc,
|
||||
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 {
|
||||
/* ============================================================
|
||||
数值格式化
|
||||
============================================================ */
|
||||
function fmt(val: number | null | undefined, digits = 2): string {
|
||||
if (val == null) return "--";
|
||||
const abs = Math.abs(val);
|
||||
if (abs >= 1e8) return (val / 1e8).toFixed(2) + "亿";
|
||||
if (abs >= 1e4) return (val / 1e4).toFixed(0) + "万";
|
||||
return val.toFixed(0);
|
||||
return val.toFixed(digits);
|
||||
}
|
||||
|
||||
/* ── 资金流向横条 ── */
|
||||
function FundFlowBar({ value, maxAbs }: { value: number | null | undefined; maxAbs: number }) {
|
||||
if (value == null) return null;
|
||||
const pct = maxAbs > 0 ? (value / maxAbs) * 100 : 0;
|
||||
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;
|
||||
/* ============================================================
|
||||
板块卡片
|
||||
============================================================ */
|
||||
function SectorCard({ item }: { item: SectorItem }) {
|
||||
const change = item.changePercent;
|
||||
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,
|
||||
);
|
||||
const inflow = item.mainNetInflow;
|
||||
const inflowIsPos = inflow >= 0;
|
||||
const inflowPct = item.mainNetInflowPercent;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<p className="text-sm font-medium truncate" title={item.name}>
|
||||
{item.name}
|
||||
@@ -231,38 +255,112 @@ function SectorBlock({ item }: { item: SectorItem }) {
|
||||
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.toFixed(2)}%
|
||||
{fmt(change)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">{formatInflow(item.turnover)}</span>
|
||||
</div>
|
||||
|
||||
{/* 主力净流入 + 明细 */}
|
||||
<div className="pt-1.5 border-t border-border/40">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-muted-foreground">主力净流入</span>
|
||||
<span
|
||||
className={`text-xs font-bold tabular-nums ${
|
||||
isPositive ? "text-red-500" : "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatInflow(inflow)}
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatMoney(item.turnover)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<FundFlowBar value={item.superLargeInflow} maxAbs={maxAbs} />
|
||||
<FundFlowBar value={item.largeInflow} maxAbs={maxAbs} />
|
||||
<FundFlowBar value={item.mediumInflow} maxAbs={maxAbs} />
|
||||
<FundFlowBar value={item.smallInflow} maxAbs={maxAbs} />
|
||||
{/* 分割线 */}
|
||||
<hr className="border-border/40" />
|
||||
|
||||
{/* 主力净流入金额 + 占比 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">主力净流入</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-bold tabular-nums ${
|
||||
inflowIsPos ? "text-red-500" : "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{inflow >= 0 ? "+" : ""}
|
||||
{formatMoney(inflow)}
|
||||
</span>
|
||||
{inflowPct != null && (
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${
|
||||
inflowIsPos ? "text-red-500/70" : "text-green-500/70"
|
||||
}`}
|
||||
>
|
||||
{inflow >= 0 ? "+" : ""}
|
||||
{fmt(inflowPct)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 资金流向明细条 */}
|
||||
<FundFlowBreakdown item={item} />
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user