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:
+1
-2
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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 API(curl_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"}
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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 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,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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