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
+1 -2
View File
@@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
from dotenv import load_dotenv from dotenv import load_dotenv
from database import init_db from database import init_db
from routes import stock, collections, shares, sectors, themes, core_stocks from routes import stock, collections, shares, themes, core_stocks
from services.daily_collector import collector_loop, cache_cleanup_loop from services.daily_collector import collector_loop, cache_cleanup_loop
load_dotenv() load_dotenv()
@@ -43,7 +43,6 @@ app.add_middleware(
app.include_router(stock.router, prefix="/api/stock") app.include_router(stock.router, prefix="/api/stock")
app.include_router(collections.router, prefix="/api/collections") app.include_router(collections.router, prefix="/api/collections")
app.include_router(shares.router, prefix="/api/share") app.include_router(shares.router, prefix="/api/share")
app.include_router(sectors.router, prefix="/api/sectors")
app.include_router(themes.router, prefix="/api/themes") app.include_router(themes.router, prefix="/api/themes")
app.include_router(core_stocks.router, prefix="/api/core-stocks") app.include_router(core_stocks.router, prefix="/api/core-stocks")
-39
View File
@@ -1,39 +0,0 @@
"""板块数据路由:行业板块、概念板块"""
from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import JSONResponse
from services import eastmoney, mootdx
router = APIRouter()
# 行业/概念通过 query 参数区分,但上游反代/CDN 可能按 path 缓存而忽略 query,
# 导致两个 tab 返回相同数据。显式禁止缓存,保证按 query 区分。
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
@router.get("", summary="板块列表")
async def sector_list(
type: str = Query("industry", description="板块类型:industry=行业板块, concept=概念板块"),
):
if type not in ("industry", "concept"):
raise HTTPException(status_code=400, detail="板块类型错误,仅支持 industry/concept")
data = await eastmoney.fetch_sector_list(type)
if data:
return JSONResponse(
{"data": data, "count": len(data), "type": type},
headers=_NO_CACHE_HEADERS,
)
# 降级:通达信 mootdx(不含实时资金流数据)
md_data = await mootdx.fetch_sector_list(type)
if md_data:
return JSONResponse(
{"data": md_data, "count": len(md_data), "type": type, "source": "mootdx"},
headers=_NO_CACHE_HEADERS,
)
return JSONResponse(
{"data": [], "count": 0, "type": type},
headers=_NO_CACHE_HEADERS,
)
+1 -325
View File
@@ -6,13 +6,11 @@ import httpx
import json import json
import os import os
import re import re
from datetime import datetime, time as dtime, timedelta, timezone from datetime import datetime
from typing import Optional, List from typing import Optional, List
from services.cache import get_cache, set_cache from services.cache import get_cache, set_cache
from services.cache import get_cache, set_cache
# ---- API Key 轮询(MX 备选源用)---- # ---- API Key 轮询(MX 备选源用)----
@@ -283,328 +281,6 @@ def get_eastmoney_market(code: str) -> str:
return "1" return "1"
return "0" return "0"
# ---- 板块数据 ----
# 从东方财富 bkzj/list.js 逆向的字段映射
# f62=主力净流入, f184=主力净流入占比
# f66=超大单净流入, f69=超大单净流入占比
# f72=大单净流入, f75=大单净流入占比
# f78=中单净流入, f81=中单净流入占比
# f84=小单净流入, f87=小单净流入占比
# f70=成交额
SECTOR_FIELDS = "f12,f14,f2,f3,f62,f184,f66,f69,f72,f75,f78,f81,f84,f87,f70"
# 东方财富板块类型映射
SECTOR_MEDIA_MAP = {
"industry": "m:90+s:4",
"concept": "m:90+t:3",
}
# 东方财富 UT 令牌管理
_em_ut: str = "8dec03ba335b81bf4ebdf7b29ec27d15"
_em_ut_lock = asyncio.Lock()
async def _refresh_em_ut() -> str:
"""
从东方财富前端 JS 中提取最新的 ut 令牌。
按优先级尝试:
1. bkzj/list.js(板块页专用)
2. common/emdataview.js(通用数据组件)
"""
urls = [
"https://data.eastmoney.com/newstatic/js/bkzj/list.js",
"https://data.eastmoney.com/newstatic/js/common/emdataview.js",
]
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://data.eastmoney.com/bkzj/hy.html",
}
async with httpx.AsyncClient() as client:
for url in urls:
try:
resp = await client.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
continue
# 匹配 ut: 'xxxx' 或 ut:'xxxx' 或 ut: "xxxx"
m = re.search(r"""ut['"]?\s*:\s*['"]([a-f0-9]{32})['"]""", resp.text)
if m:
token = m.group(1)
print(f"[eastmoney] 已刷新 UT 令牌: {token[:8]}...")
return token
except Exception as e:
print(f"[eastmoney] 获取 UT 失败({url}): {e}")
return _em_ut # 保底返回当前值
async def get_em_ut(force_refresh: bool = False) -> str:
"""获取当前 UT,必要时刷新"""
global _em_ut
if force_refresh:
async with _em_ut_lock:
_em_ut = await _refresh_em_ut()
return _em_ut
# ---- 板块数据(市场时间感知缓存)----
_CST = timezone(timedelta(hours=8)) # 北京时间
_TRADING_MORNING = (dtime(9, 30), dtime(11, 30))
_TRADING_AFTERNOON = (dtime(13, 0), dtime(15, 0))
def _cst_now() -> datetime:
return datetime.now(_CST)
def _is_trading_time() -> bool:
"""判断当前是否为 A 股交易时段(周一至周五 9:30-11:30 / 13:00-15:00"""
now = _cst_now()
if now.weekday() >= 5:
return False
t = now.time()
return (_TRADING_MORNING[0] <= t <= _TRADING_MORNING[1]
or _TRADING_AFTERNOON[0] <= t <= _TRADING_AFTERNOON[1])
def _sector_ttl_hours() -> int:
"""根据是否在交易时段返回缓存 TTL
- 交易时段: 2 分钟(数据持续变化)
- 非交易时段: 18 小时(覆盖到下一个交易日)
"""
return 0 if _is_trading_time() else 18
# curl_cffi 模拟 Chrome TLS 指纹
from curl_cffi.requests import AsyncSession
_sector_session: Optional[AsyncSession] = None
def _get_sector_session() -> AsyncSession:
global _sector_session
if _sector_session is None:
_sector_session = AsyncSession(
impersonate="chrome131",
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Referer": "https://data.eastmoney.com/bkzj/hy.html",
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9",
},
timeout=5,
)
return _sector_session
# 内存缓存(加速交易时段频繁请求)
_sector_cache: dict[str, tuple[list[dict], float]] = {}
_SECTOR_MEM_TTL = 60
async def fetch_sector_list(sector_type: str) -> list[dict]:
# 1. 内存缓存
now = time.time()
if sector_type in _sector_cache:
data, ts = _sector_cache[sector_type]
if now - ts < _SECTOR_MEM_TTL:
return data
cache_key = f"sector_list:{sector_type}"
# 2. 非交易时段:走磁盘持久缓存
if not _is_trading_time():
cached = get_cache(cache_key)
if cached is not None:
data = json.loads(cached)
_sector_cache[sector_type] = (data, now)
return data
# 3. 并发请求 push2 和 akshare,优先使用 push2
push2_task = asyncio.create_task(_fetch_push2(sector_type))
akshare_task = asyncio.create_task(_fetch_akshare(sector_type))
push2_data = await push2_task
if push2_data:
akshare_task.cancel()
try:
await akshare_task
except asyncio.CancelledError:
pass
_sector_cache[sector_type] = (push2_data, now)
ttl = _sector_ttl_hours()
if ttl > 0:
set_cache(cache_key, json.dumps(push2_data, ensure_ascii=False), ttl_hours=ttl)
return push2_data
# push2 失败,用 akshare(不写磁盘缓存)
akshare_data = await akshare_task
if akshare_data:
_sector_cache[sector_type] = (akshare_data, now)
return akshare_data
async def _fetch_push2(sector_type: str) -> list[dict]:
"""东方财富 push2 APIcurl_cffi 模拟浏览器 TLS 指纹)"""
fs = SECTOR_MEDIA_MAP.get(sector_type)
if not fs:
return []
session = _get_sector_session()
ut = await get_em_ut()
for attempt in range(2):
url = (
f"https://push2.eastmoney.com/api/qt/clist/get"
f"?fs={fs}&fields={SECTOR_FIELDS}"
f"&fid=f62&po=1&pz=500&pn=1&np=1&fltt=2"
f"&invt=2&ut={ut}"
)
try:
resp = await session.get(url)
if resp.status_code != 200:
if attempt == 0:
await asyncio.sleep(1)
continue
return []
result = resp.json()
if result.get("rc") != 0:
return []
diff = result.get("data", {}).get("diff", [])
items = []
for item in diff:
items.append({
"code": item.get("f12", ""),
"name": item.get("f14", ""),
"level": item.get("f2"),
"changePercent": item.get("f3"),
"changeAmount": None,
"mainNetInflow": item.get("f62", 0) or 0,
"mainNetInflowPercent": item.get("f184", 0),
"superLargeInflow": item.get("f66", 0) or 0,
"superLargeInflowPercent": item.get("f69", 0),
"largeInflow": item.get("f72", 0) or 0,
"largeInflowPercent": item.get("f75", 0),
"mediumInflow": item.get("f78", 0) or 0,
"mediumInflowPercent": item.get("f81", 0),
"smallInflow": item.get("f84", 0) or 0,
"smallInflowPercent": item.get("f87", 0),
"turnover": item.get("f70", 0) or 0,
})
return items
except Exception as e:
err = str(e)
print(f"[eastmoney] push2 获取{sector_type}板块失败(attempt {attempt+1}): {err[:80]}")
# UT 可能过期,尝试刷新
if "disconnect" in err.lower() or "refused" in err.lower() or attempt == 1:
await get_em_ut(force_refresh=True)
ut = _em_ut
# 先尝试更新现有会话的 headers
try:
session.headers.update({"Referer": "https://data.eastmoney.com/bkzj/hy.html"})
except Exception:
pass
# 重建会话(TLS 指纹可能会被缓存)
global _sector_session
_sector_session = None
session = _get_sector_session()
if attempt == 0:
await asyncio.sleep(1)
return []
import akshare as ak
async def _fetch_akshare(sector_type: str) -> list[dict]:
"""akshare 降级方案(东方财富数据源)"""
loop = asyncio.get_event_loop()
code_map_key = f"board_codes:{sector_type}"
def _build_code_map():
"""获取板块代码映射(HTTP 较慢,结果单独缓存 24h)"""
code_map = {}
try:
if sector_type == "industry":
code_df = ak.stock_board_industry_name_em()
else:
code_df = ak.stock_board_concept_name_em()
if code_df is not None and not code_df.empty:
for _, r in code_df.iterrows():
code_map[str(r.get("f14", ""))] = str(r.get("f12", ""))
except Exception:
try:
if sector_type == "industry":
code_df = ak.stock_board_industry_name_ths()
else:
code_df = ak.stock_board_concept_name_ths()
if code_df is not None and not code_df.empty:
for _, r in code_df.iterrows():
code_map[str(r.get("name", ""))] = str(r.get("code", ""))
except Exception:
pass
return code_map
def _get_fund_flow():
if sector_type == "industry":
return ak.stock_fund_flow_industry()
else:
return ak.stock_fund_flow_concept()
# 1. 尝试从缓存读取 code_map
code_map = {}
cached_map = get_cache(code_map_key)
if cached_map is not None:
code_map = json.loads(cached_map)
try:
if code_map:
# 已有缓存,只需获取资金流
df = await loop.run_in_executor(None, _get_fund_flow)
else:
# 首次:code_map + 资金流并发获取
map_data, df = await asyncio.gather(
loop.run_in_executor(None, _build_code_map),
loop.run_in_executor(None, _get_fund_flow),
)
if map_data:
code_map = map_data
set_cache(code_map_key, json.dumps(code_map, ensure_ascii=False), ttl_hours=24)
if df is None or df.empty:
return []
df = df.sort_values("净额", ascending=False)
items = []
for _, row in df.iterrows():
name = str(row.get("行业", "")).strip()
inflow = float(row.get("流入资金", 0) or 0) * 100000000
outflow = float(row.get("流出资金", 0) or 0) * 100000000
items.append({
"code": code_map.get(name, ""),
"name": name,
"level": float(row.get("行业指数") or 0),
"changePercent": float(row.get("行业-涨跌幅") or 0),
"changeAmount": None,
"mainNetInflow": float(row.get("净额", 0) or 0) * 100000000,
"mainNetInflowPercent": None,
"superLargeInflow": None,
"superLargeInflowPercent": None,
"largeInflow": None,
"largeInflowPercent": None,
"mediumInflow": None,
"mediumInflowPercent": None,
"smallInflow": None,
"smallInflowPercent": None,
"turnover": inflow + outflow,
})
return items
except Exception as e:
print(f"[eastmoney] akshare 获取{sector_type}板块失败: {e}")
return []
# ---- 公司概况 ---- # ---- 公司概况 ----
_F10_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"} _F10_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"}
-58
View File
@@ -3,7 +3,6 @@
通过 TCP 协议直连通达信行情服务器,不走 HTTP,不会被限流。 通过 TCP 协议直连通达信行情服务器,不走 HTTP,不会被限流。
主要用途: 主要用途:
- K线数据:主数据源(稳定可靠) - K线数据:主数据源(稳定可靠)
- 板块数据:东方财富 push2 的降级方案
""" """
import asyncio import asyncio
@@ -69,60 +68,3 @@ async def fetch_kline_history(code: str, days: int = 90) -> Optional[List[dict]]
except Exception as e: except Exception as e:
print(f"[mootdx] fetch_kline error: {e}") print(f"[mootdx] fetch_kline error: {e}")
return None return None
# ---- 板块数据(东方财富降级方案)----
def _sync_fetch_sectors(sector_type: str) -> Optional[list]:
from mootdx.consts import MARKET_SH, MARKET_SZ
client = _create_client()
# block() 返回 DataFrame,列:code, name 等
# 按板块类型过滤
block_df = client.block()
if block_df is None or block_df.empty:
return None
items = []
for _, row in block_df.iterrows():
name = str(row.get("name", "") or row.get("blockname", ""))
code = str(row.get("code", "") or row.get("blockcode", ""))
if not code or not name:
continue
items.append(
{
"code": code,
"name": name,
"level": None,
"changePercent": None,
"changeAmount": None,
"mainNetInflow": 0,
"mainNetInflowPercent": None,
"superLargeInflow": None,
"superLargeInflowPercent": None,
"largeInflow": None,
"largeInflowPercent": None,
"mediumInflow": None,
"mediumInflowPercent": None,
"smallInflow": None,
"smallInflowPercent": None,
"turnover": 0,
}
)
return items if items else None
async def fetch_sector_list(sector_type: str) -> Optional[List[dict]]:
"""获取板块列表(东方财富的降级方案,仅含代码和名称)"""
try:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _sync_fetch_sectors, sector_type)
except ImportError:
return None
except Exception as e:
print(f"[mootdx] fetch_sectors error: {e}")
return None
-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 rootRouteImport } from './routes/__root'
import { Route as ThemesRouteImport } from './routes/themes' 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 HotMapRouteImport } from './routes/hot-map'
import { Route as CoreStocksRouteImport } from './routes/core-stocks' import { Route as CoreStocksRouteImport } from './routes/core-stocks'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
@@ -23,11 +22,6 @@ const ThemesRoute = ThemesRouteImport.update({
path: '/themes', path: '/themes',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const SectorsRoute = SectorsRouteImport.update({
id: '/sectors',
path: '/sectors',
getParentRoute: () => rootRouteImport,
} as any)
const HotMapRoute = HotMapRouteImport.update({ const HotMapRoute = HotMapRouteImport.update({
id: '/hot-map', id: '/hot-map',
path: '/hot-map', path: '/hot-map',
@@ -63,7 +57,6 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/core-stocks': typeof CoreStocksRoute '/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute '/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute '/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute '/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute '/stock/$code': typeof StockCodeRoute
@@ -73,7 +66,6 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/core-stocks': typeof CoreStocksRoute '/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute '/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute '/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute '/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute '/stock/$code': typeof StockCodeRoute
@@ -84,7 +76,6 @@ export interface FileRoutesById {
'/': typeof IndexRoute '/': typeof IndexRoute
'/core-stocks': typeof CoreStocksRoute '/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute '/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute '/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute '/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute '/stock/$code': typeof StockCodeRoute
@@ -96,7 +87,6 @@ export interface FileRouteTypes {
| '/' | '/'
| '/core-stocks' | '/core-stocks'
| '/hot-map' | '/hot-map'
| '/sectors'
| '/themes' | '/themes'
| '/share/$code' | '/share/$code'
| '/stock/$code' | '/stock/$code'
@@ -106,7 +96,6 @@ export interface FileRouteTypes {
| '/' | '/'
| '/core-stocks' | '/core-stocks'
| '/hot-map' | '/hot-map'
| '/sectors'
| '/themes' | '/themes'
| '/share/$code' | '/share/$code'
| '/stock/$code' | '/stock/$code'
@@ -116,7 +105,6 @@ export interface FileRouteTypes {
| '/' | '/'
| '/core-stocks' | '/core-stocks'
| '/hot-map' | '/hot-map'
| '/sectors'
| '/themes' | '/themes'
| '/share/$code' | '/share/$code'
| '/stock/$code' | '/stock/$code'
@@ -127,7 +115,6 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
CoreStocksRoute: typeof CoreStocksRoute CoreStocksRoute: typeof CoreStocksRoute
HotMapRoute: typeof HotMapRoute HotMapRoute: typeof HotMapRoute
SectorsRoute: typeof SectorsRoute
ThemesRoute: typeof ThemesRoute ThemesRoute: typeof ThemesRoute
ShareCodeRoute: typeof ShareCodeRoute ShareCodeRoute: typeof ShareCodeRoute
StockCodeRoute: typeof StockCodeRoute StockCodeRoute: typeof StockCodeRoute
@@ -143,13 +130,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ThemesRouteImport preLoaderRoute: typeof ThemesRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/sectors': {
id: '/sectors'
path: '/sectors'
fullPath: '/sectors'
preLoaderRoute: typeof SectorsRouteImport
parentRoute: typeof rootRouteImport
}
'/hot-map': { '/hot-map': {
id: '/hot-map' id: '/hot-map'
path: '/hot-map' path: '/hot-map'
@@ -199,7 +179,6 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
CoreStocksRoute: CoreStocksRoute, CoreStocksRoute: CoreStocksRoute,
HotMapRoute: HotMapRoute, HotMapRoute: HotMapRoute,
SectorsRoute: SectorsRoute,
ThemesRoute: ThemesRoute, ThemesRoute: ThemesRoute,
ShareCodeRoute: ShareCodeRoute, ShareCodeRoute: ShareCodeRoute,
StockCodeRoute: StockCodeRoute, StockCodeRoute: StockCodeRoute,
-6
View File
@@ -212,12 +212,6 @@ function Index() {
</h1> </h1>
<p className="text-sm md:text-base text-muted-foreground"></p> <p className="text-sm md:text-base text-muted-foreground"></p>
<div className="mt-3 flex items-center justify-center gap-2"> <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"> <Link to="/themes">
<Button variant="outline" size="sm" className="gap-1.5 text-xs"> <Button variant="outline" size="sm" className="gap-1.5 text-xs">
<Flame className="h-3.5 w-3.5" /> <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>
);
}