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:
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user