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
-47
View File
@@ -430,50 +430,3 @@ export async function fetchFinancialData(code: string, years: number = 5): Promi
}
}
// ---- 板块数据 ----
export interface SectorItem {
code: string;
name: string;
level: number | null;
changePercent: number | null;
changeAmount: number | null;
mainNetInflow: number;
mainNetInflowPercent: number | null;
superLargeInflow: number | null;
superLargeInflowPercent: number | null;
largeInflow: number | null;
largeInflowPercent: number | null;
mediumInflow: number | null;
mediumInflowPercent: number | null;
smallInflow: number | null;
smallInflowPercent: number | null;
turnover: number;
}
export type SectorType = "industry" | "concept";
export interface SectorResponse {
data: SectorItem[];
count: number;
type: SectorType;
}
/**
* 获取东方财富板块列表(按主力净流入排序)
* @param type industry=行业板块, concept=概念板块
*/
export async function fetchSectors(type: SectorType, signal?: AbortSignal): Promise<SectorItem[]> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/sectors?type=${type}`;
try {
const resp = await fetch(url, { method: "GET", signal, cache: "no-store" });
if (!resp.ok) return [];
const result: SectorResponse = await resp.json();
return result.data || [];
} catch (err) {
console.error("[stock-api] 获取板块数据失败:", err);
return [];
}
}
-21
View File
@@ -10,7 +10,6 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as ThemesRouteImport } from './routes/themes'
import { Route as SectorsRouteImport } from './routes/sectors'
import { Route as HotMapRouteImport } from './routes/hot-map'
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
import { Route as IndexRouteImport } from './routes/index'
@@ -23,11 +22,6 @@ const ThemesRoute = ThemesRouteImport.update({
path: '/themes',
getParentRoute: () => rootRouteImport,
} as any)
const SectorsRoute = SectorsRouteImport.update({
id: '/sectors',
path: '/sectors',
getParentRoute: () => rootRouteImport,
} as any)
const HotMapRoute = HotMapRouteImport.update({
id: '/hot-map',
path: '/hot-map',
@@ -63,7 +57,6 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
@@ -73,7 +66,6 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
@@ -84,7 +76,6 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
@@ -96,7 +87,6 @@ export interface FileRouteTypes {
| '/'
| '/core-stocks'
| '/hot-map'
| '/sectors'
| '/themes'
| '/share/$code'
| '/stock/$code'
@@ -106,7 +96,6 @@ export interface FileRouteTypes {
| '/'
| '/core-stocks'
| '/hot-map'
| '/sectors'
| '/themes'
| '/share/$code'
| '/stock/$code'
@@ -116,7 +105,6 @@ export interface FileRouteTypes {
| '/'
| '/core-stocks'
| '/hot-map'
| '/sectors'
| '/themes'
| '/share/$code'
| '/stock/$code'
@@ -127,7 +115,6 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
CoreStocksRoute: typeof CoreStocksRoute
HotMapRoute: typeof HotMapRoute
SectorsRoute: typeof SectorsRoute
ThemesRoute: typeof ThemesRoute
ShareCodeRoute: typeof ShareCodeRoute
StockCodeRoute: typeof StockCodeRoute
@@ -143,13 +130,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ThemesRouteImport
parentRoute: typeof rootRouteImport
}
'/sectors': {
id: '/sectors'
path: '/sectors'
fullPath: '/sectors'
preLoaderRoute: typeof SectorsRouteImport
parentRoute: typeof rootRouteImport
}
'/hot-map': {
id: '/hot-map'
path: '/hot-map'
@@ -199,7 +179,6 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
CoreStocksRoute: CoreStocksRoute,
HotMapRoute: HotMapRoute,
SectorsRoute: SectorsRoute,
ThemesRoute: ThemesRoute,
ShareCodeRoute: ShareCodeRoute,
StockCodeRoute: StockCodeRoute,
-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>
);
}