diff --git a/src/routes/sectors.tsx b/src/routes/sectors.tsx index bf738e5..1c5a99c 100644 --- a/src/routes/sectors.tsx +++ b/src/routes/sectors.tsx @@ -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 = { + mainNetInflow: "资金", + mainNetInflowPercent: "涨幅", +}; + +/* ============================================================ + 页面组件 + ============================================================ */ function SectorsPage() { + const queryClient = useQueryClient(); const [tab, setTab] = useState("industry"); const [sortKey, setSortKey] = useState("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(() => { 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 (
- {/* 顶栏 */} + {/* ── 顶栏 ── */}
@@ -77,13 +120,16 @@ function SectorsPage() {
- {/* Tab 切换:行业 / 概念 */} + {/* ── Tab 切换 ── */}
{TABS.map((t) => ( @@ -102,31 +148,65 @@ function SectorsPage() {
- {/* 排序切换 */} + {/* ── 排序切换 + 统计 ── */}

- 共 {sorted.length} 个板块 · {tab === "industry" ? "行业" : "概念"} + 共 {cachedData.length} 个板块 + {isFetching && ( + + · 刷新中… + + )}

- toggleSort("mainNetInflow")} /> - toggleSort("changePercent")} /> + {(Object.keys(SORT_LABEL) as SortKey[]).map((key) => ( + + ))}
- {/* 卡片列表 */} + {/* ── 内容区 ── */}
+ {/* 加载骨架 */} {isLoading ? (
{Array.from({ length: 20 }).map((_, i) => ( -
+
))}
- ) : sorted.length === 0 ? ( -
暂无数据
+ ) : isError ? ( + /* 请求失败 */ +
+

数据加载失败

+ +
+ ) : cachedData.length === 0 ? ( + /* 数据为空 */ +
+ 暂无{tab === "industry" ? "行业" : "概念"}板块数据 +
) : ( + /* 板块卡片网格 */
- {sorted.map((item) => ( - + {displayData.map((item) => ( + ))}
)} @@ -135,83 +215,27 @@ function SectorsPage() { ); } -function SortButton({ - label, - active, - asc, - onClick, -}: { - label: string; - active: boolean; - asc: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -/* ── 数值格式化 ── */ -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 ( -
-
-
-
- - {formatInflow(value)} - -
- ); -} - -/* ── 单个板块卡片 ── */ -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 ( - - {/* 板块名称 */} + + {/* 板块名称 + 代码 */}

{item.name} @@ -231,38 +255,112 @@ function SectorBlock({ item }: { item: SectorItem }) { change >= 0 ? "text-red-500" : "text-green-500" }`} > - {change >= 0 ? : } + {change >= 0 ? ( + + ) : ( + + )} {change >= 0 ? "+" : ""} - {change.toFixed(2)}% + {fmt(change)}% ) : ( -- )} - {formatInflow(item.turnover)} + + {formatMoney(item.turnover)} +

- {/* 主力净流入 + 明细 */} -
-
- 主力净流入 + {/* 分割线 */} +
+ + {/* 主力净流入金额 + 占比 */} +
+ 主力净流入 +
- {isPositive ? "+" : ""} - {formatInflow(inflow)} + {inflow >= 0 ? "+" : ""} + {formatMoney(inflow)} -
- -
- - - - + {inflowPct != null && ( + + {inflow >= 0 ? "+" : ""} + {fmt(inflowPct)}% + + )}
+ + {/* 资金流向明细条 */} + ); } + +/* ============================================================ + 资金流向明细 — 超大单 / 大单 / 中单 / 小单 + ============================================================ */ +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 ( +
+ {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 ( +
+ + {f.label} + +
+
+
+ + {formatMoney(val)} + +
+ ); + })} +
+ ); +}