refactor: 删除板块资金流向,题材板块功能更全

- 删除前端 /sectors 页面、首页入口按钮、stock-api 板块类型与 fetchSectors
- 删除后端 /api/sectors 路由,main.py 移除注册
- eastmoney.py 移除板块数据段(_fetch_push2/_fetch_akshare/UT令牌管理),
  清理重复 import 与无用 datetime 子导入
- mootdx.py 移除板块降级方案(fetch_sector_list)
- routeTree.gen.ts 由 build 自动重新生成,移除 sectors 路由

题材热点/热点穿透/核心股已覆盖板块能力,功能更全,板块资金流向不再需要

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-11 18:50:54 +08:00
co-authored by Claude
parent b0dbeef3fd
commit 90569918a3
8 changed files with 2 additions and 864 deletions
-6
View File
@@ -212,12 +212,6 @@ function Index() {
</h1>
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
<div className="mt-3 flex items-center justify-center gap-2">
<Link to="/sectors">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<TrendingUp className="h-3.5 w-3.5" />
板块资金流向
</Button>
</Link>
<Link to="/themes">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<Flame className="h-3.5 w-3.5" />
-366
View File
@@ -1,366 +0,0 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMemo, useState } from "react";
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";
export const Route = createFileRoute("/sectors")({
component: SectorsPage,
});
/* ============================================================
Tab 定义:行业 / 概念
============================================================ */
const TABS: { key: SectorType; label: string }[] = [
{ key: "industry", label: "行业" },
{ key: "concept", label: "概念" },
];
/* ============================================================
排序维度
============================================================ */
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); // 默认升序
// ── 行业 / 概念各自独立 Query,缓存完全隔离 ──
const industryQ = useQuery({
queryKey: ["sectors", "industry"],
queryFn: ({ signal }) => fetchSectors("industry", signal),
staleTime: 30_000,
retry: false,
});
const conceptQ = useQuery({
queryKey: ["sectors", "concept"],
queryFn: ({ signal }) => fetchSectors("concept", signal),
staleTime: 30_000,
retry: false,
});
// 当前激活的 tab 查询
const activeQuery = tab === "industry" ? industryQ : conceptQ;
const { isLoading, isFetching, isError, refetch } = activeQuery;
/* ═══════════════════════════════════════════════════════
三层数据分离:缓存 → 排序 → 展示
═══════════════════════════════════════════════════════ */
// ① 缓存数据 — React Query 从后端拿到的原始数据
const cachedData: SectorItem[] = activeQuery.data ?? [];
// ② 排序数据 — 按当前排序规则在内存中重排
const sortedData = useMemo<SectorItem[]>(() => {
const dir = asc ? 1 : -1;
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;
});
}, [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 {
setSortKey(key);
setAsc(true); // 切新维度默认升序
}
};
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">
<Link to="/" className="hover:opacity-70 transition-opacity">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold">板块资金流向</h1>
</div>
<button
onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
</button>
</div>
</header>
{/* ── 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) => (
<button
key={t.key}
onClick={() => handleTab(t.key)}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
tab === t.key
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t.label}板块
</button>
))}
</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">
共 {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">
{(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-40" />
))}
</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">
{displayData.map((item) => (
<SectorCard key={item.code} item={item} />
))}
</div>
)}
</div>
</div>
);
}
/* ============================================================
数值格式化
============================================================ */
function fmt(val: number | null | undefined, digits = 2): string {
if (val == null) return "--";
return val.toFixed(digits);
}
/* ============================================================
板块卡片
============================================================ */
function SectorCard({ item }: { item: SectorItem }) {
const change = item.changePercent;
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-1.5">
{/* 板块名称 + 代码 */}
<div className="flex items-center justify-between gap-1">
<p className="text-sm font-medium truncate" title={item.name}>
{item.name}
</p>
{item.code && (
<span className="shrink-0 text-[9px] text-muted-foreground/60 font-mono">
{item.code.replace("BK", "")}
</span>
)}
</div>
{/* 涨跌幅 + 成交额 */}
<div className="flex items-center justify-between">
{change != null ? (
<span
className={`inline-flex items-center gap-0.5 text-xs font-semibold ${
change >= 0 ? "text-red-500" : "text-green-500"
}`}
>
{change >= 0 ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{change >= 0 ? "+" : ""}
{fmt(change)}%
</span>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
<span className="text-[10px] text-muted-foreground">
{formatMoney(item.turnover)}
</span>
</div>
{/* 分割线 */}
<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>
);
}