Compare commits
7
Commits
f98a9255a5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48522cbfea | ||
|
|
a6497caf73 | ||
|
|
8cf4c40e2b | ||
|
|
90569918a3 | ||
|
|
b0dbeef3fd | ||
|
|
eb66ab02d0 | ||
|
|
7fe8074e22 |
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# auvops.sh - AUV 容器内运维工具(在容器内直接执行,非宿主机 docker exec)
|
||||
#
|
||||
# 拷贝进容器 /app(backend 根)后运行。后续新增运维能力都收敛到这个文件:
|
||||
# 加一个 cmd_xxx 函数 + 在 main 的 case 里注册一行即可。
|
||||
#
|
||||
# 用法(容器内):
|
||||
# ./auvops.sh cache-clear # 清空全部缓存(当日数据全新)
|
||||
# ./auvops.sh clean-expired # 只清理已过期的缓存
|
||||
# ./auvops.sh cache-count # 查看缓存条数
|
||||
# ./auvops.sh recollect [DATE] # 删除并重采指定交易日(默认当天)
|
||||
# ./auvops.sh sh # 进入交互式 shell
|
||||
# ./auvops.sh help # 查看帮助
|
||||
#
|
||||
# 依赖: 容器内 python 可用(能 import services.*),工作目录自动切到脚本所在目录。
|
||||
#
|
||||
# 示例:
|
||||
# ./auvops.sh cache-clear
|
||||
# ./auvops.sh recollect 2026-08-10
|
||||
# ./auvops.sh cache-count
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# 容器内 backend 根:脚本所在目录(/app)
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${ROOT}"
|
||||
|
||||
# 容器内用 python;本机验证时可 PY=./venv/bin/python ./auvops.sh ...
|
||||
PY="${PY:-python}"
|
||||
"${PY}" -c "import services" >/dev/null 2>&1 || {
|
||||
echo "❌ 无法在 ${ROOT} 下 import services(确认已在容器内 /app 且 python 可用)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 在容器内跑 python 代码。日期等参数通过环境变量传,避免拼接进代码字符串。
|
||||
# 用法: py_env "KEY1=val1" "KEY2=val2" <<'EOF'
|
||||
# <python 代码>
|
||||
# EOF
|
||||
py_env() {
|
||||
local env_args=()
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
env_args+=("${1%%=*}=${1#*=}"); shift
|
||||
done
|
||||
env "${env_args[@]}" "${PY}" -
|
||||
}
|
||||
|
||||
# ---- 子命令实现 ----
|
||||
|
||||
# 清空全部缓存(当日数据全新)
|
||||
cmd_cache_clear() {
|
||||
echo "① 清空全部缓存"
|
||||
"${PY}" - <<'EOF'
|
||||
from services.cache import clear_all
|
||||
clear_all()
|
||||
print(" cache 表已清空")
|
||||
EOF
|
||||
echo "✅ 缓存已清空"
|
||||
}
|
||||
|
||||
# 只清理已过期缓存
|
||||
cmd_clean_expired() {
|
||||
echo "① 清理过期缓存"
|
||||
"${PY}" - <<'EOF'
|
||||
from services.cache import clean_expired
|
||||
clean_expired()
|
||||
print(" 过期缓存已清理")
|
||||
EOF
|
||||
echo "✅ 完成"
|
||||
}
|
||||
|
||||
# 查看缓存条数
|
||||
cmd_cache_count() {
|
||||
"${PY}" - <<'EOF'
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
n = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
|
||||
print(f"缓存条数: {n}")
|
||||
finally:
|
||||
conn.close()
|
||||
EOF
|
||||
}
|
||||
|
||||
# 删除指定交易日旧数据,并用新选股逻辑重新采集入库
|
||||
cmd_recollect() {
|
||||
local date="${1:-$(date +%F)}"
|
||||
if ! [[ "${date}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||
echo "❌ 日期格式错误:${date}(应为 YYYY-MM-DD)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "================================================"
|
||||
echo "🚀 容器: ${HOSTNAME:-unknown} 交易日: ${date}"
|
||||
echo "================================================"
|
||||
|
||||
echo "① 删除 ${date} 旧数据"
|
||||
py_env "TARGET_DATE=${date}" <<'EOF'
|
||||
import os
|
||||
from database import get_connection
|
||||
date = os.environ["TARGET_DATE"]
|
||||
conn = get_connection()
|
||||
try:
|
||||
for tbl in ("daily_core_stocks", "daily_core_stock_themes", "daily_top_themes"):
|
||||
cur = conn.execute("DELETE FROM " + tbl + " WHERE trade_date = ?", (date,))
|
||||
print(f" {tbl}: 删除 {cur.rowcount} 行")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
EOF
|
||||
|
||||
echo "② 用新选股逻辑重采 ${date}"
|
||||
py_env "TARGET_DATE=${date}" <<'EOF'
|
||||
import asyncio
|
||||
import os
|
||||
from services.daily_collector import collect_daily
|
||||
result = asyncio.run(collect_daily(os.environ["TARGET_DATE"]))
|
||||
print(" 结果:", result)
|
||||
EOF
|
||||
echo "✅ 完成"
|
||||
}
|
||||
|
||||
# 进入交互式 shell
|
||||
cmd_sh() {
|
||||
"${PY}" || true
|
||||
}
|
||||
|
||||
# 帮助
|
||||
usage() {
|
||||
awk 'NR >= 2 && /^#/ { sub(/^# ?/, ""); print; next } NR >= 2 && !/^#/ { exit }' "${BASH_SOURCE[0]}"
|
||||
}
|
||||
|
||||
# ---- 入口 ----
|
||||
main() {
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
local cmd="$1"; shift
|
||||
case "${cmd}" in
|
||||
cache-clear|cc) cmd_cache_clear "$@" ;;
|
||||
clean-expired) cmd_clean_expired "$@" ;;
|
||||
cache-count) cmd_cache_count "$@" ;;
|
||||
recollect) cmd_recollect "$@" ;;
|
||||
sh|shell|python) cmd_sh "$@" ;;
|
||||
help|-h|--help) usage ;;
|
||||
*) echo "❌ 未知命令: ${cmd}(./auvops.sh help 查看用法)" >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+10
-8
@@ -7,8 +7,8 @@ from contextlib import asynccontextmanager
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from database import init_db
|
||||
from routes import stock, collections, shares, sectors, themes, core_stocks
|
||||
from services.daily_collector import collector_loop
|
||||
from routes import stock, collections, shares, themes, core_stocks
|
||||
from services.daily_collector import collector_loop, cache_cleanup_loop
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -17,14 +17,17 @@ load_dotenv()
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
collector_task = asyncio.create_task(collector_loop())
|
||||
cache_cleanup_task = asyncio.create_task(cache_cleanup_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
collector_task.cancel()
|
||||
try:
|
||||
await collector_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
for t in (collector_task, cache_cleanup_task):
|
||||
t.cancel()
|
||||
for t in (collector_task, cache_cleanup_task):
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="AUV API", version="1.0.0", lifespan=lifespan)
|
||||
@@ -40,7 +43,6 @@ app.add_middleware(
|
||||
app.include_router(stock.router, prefix="/api/stock")
|
||||
app.include_router(collections.router, prefix="/api/collections")
|
||||
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(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,
|
||||
)
|
||||
@@ -52,6 +52,39 @@ async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/{theme_code}/news", summary="题材相关新闻(分页)")
|
||||
async def theme_news(
|
||||
theme_code: str,
|
||||
page_num: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(10, ge=1, le=50, description="每页条数"),
|
||||
max_eu_time: str = Query("", description="分页游标(上一页返回的 maxEuTime)"),
|
||||
):
|
||||
result = await themes.fetch_theme_news(theme_code, page_num, max_eu_time, page_size)
|
||||
if result is None:
|
||||
return JSONResponse(
|
||||
{"data": None, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"data": result, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{theme_code}/quote", summary="单题材实时行情(强度/热度/涨幅)")
|
||||
async def theme_quote(theme_code: str):
|
||||
result = await themes.fetch_theme_quote(theme_code)
|
||||
if result is None:
|
||||
return JSONResponse(
|
||||
{"data": None, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"data": result, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{theme_code}/detail", summary="题材详情")
|
||||
async def theme_detail(theme_code: str):
|
||||
data = await themes.fetch_theme_detail(theme_code)
|
||||
|
||||
@@ -56,3 +56,13 @@ def clean_expired():
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def clear_all():
|
||||
"""清空全部缓存(每日开盘后 9:31 调用,保证当日数据全新)"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("DELETE FROM cache")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -9,19 +9,23 @@ from datetime import datetime, time as dtime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from database import get_connection
|
||||
from services.cache import clear_all
|
||||
from services.themes import fetch_theme_list, _build_theme_graph
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
# 每天采集的后台任务:每 300 秒(5 分钟)检查一次
|
||||
CHECK_INTERVAL_SECONDS = 300
|
||||
COLLECT_AFTER_TIME = dtime(15, 0) # 收盘后 15:00 开始允许采集
|
||||
COLLECT_AFTER_TIME = dtime(15, 1) # 收盘后 15:01 开始允许采集(留 1 分钟等收盘数据稳定)
|
||||
CORE_STOCK_LIMIT = 100 # 题材领涨股涨幅前100
|
||||
TOP_THEME_LIMIT = 20 # 题材涨幅前20
|
||||
HOTMAP_TOP_N = 50 # 热点穿透采样题材数:涨幅榜+热度榜各取前50,合并(与热点穿透页一致)
|
||||
HOTMAP_CORE_LIMIT = 100 # 热点穿透核心股前100(按覆盖题材数降序)
|
||||
CORE_COVER_THRESHOLD = 2 # 热点穿透核心股门槛:覆盖题材数 ≥2
|
||||
|
||||
# 每日缓存清理:交易日 9:31 清空全部缓存,保证开盘后数据全新
|
||||
CLEANUP_TIME = dtime(9, 31)
|
||||
|
||||
|
||||
def _is_trading_day(d: datetime) -> bool:
|
||||
"""仅按工作日判断:周一至周五视为交易日,不处理法定节假日"""
|
||||
@@ -171,7 +175,7 @@ async def collect_daily(trade_date: str, dry_run: bool = False) -> dict:
|
||||
|
||||
|
||||
async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
|
||||
"""后台循环:每个交易日 15:00 后自动采集当日数据(幂等)"""
|
||||
"""后台循环:每个交易日 15:01 后自动采集当日数据(幂等)"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(_CST)
|
||||
@@ -185,3 +189,45 @@ async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
|
||||
if stop is not None and stop.is_set():
|
||||
break
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def _next_cleanup_dt(now: datetime) -> datetime:
|
||||
"""计算下一个缓存清理时刻:最近一个工作日 9:31(今天已过则取下一个工作日)"""
|
||||
for days in range(0, 8):
|
||||
d = (now + timedelta(days=days)).date()
|
||||
if d.weekday() >= 5: # 跳过周末
|
||||
continue
|
||||
dt = datetime(d.year, d.month, d.day, CLEANUP_TIME.hour, CLEANUP_TIME.minute, tzinfo=_CST)
|
||||
if dt > now:
|
||||
return dt
|
||||
return now + timedelta(days=1) # 兜底:理论不可达
|
||||
|
||||
|
||||
async def cache_cleanup_loop(stop: Optional[asyncio.Event] = None) -> None:
|
||||
"""后台循环:每个交易日 9:31 清空全部缓存,保证开盘后读到全新数据
|
||||
|
||||
到点前精确 sleep 至 9:31;若进程在 9:31 后启动,则等下一个交易日。
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(_CST)
|
||||
next_dt = _next_cleanup_dt(now)
|
||||
delay = max(0, int((next_dt - now).total_seconds()))
|
||||
if stop is not None:
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=delay)
|
||||
except asyncio.TimeoutError:
|
||||
pass # 到点
|
||||
if stop.is_set():
|
||||
break
|
||||
else:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
clear_all()
|
||||
print(f"[cache-cleanup] 已清空全部缓存: {datetime.now(_CST).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
print("[cache-cleanup] 清理异常:")
|
||||
traceback.print_exc()
|
||||
await asyncio.sleep(60) # 出错 1 分钟后再试
|
||||
|
||||
@@ -6,13 +6,11 @@ import httpx
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, time as dtime, timedelta, timezone
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from services.cache import get_cache, set_cache
|
||||
|
||||
from services.cache import get_cache, set_cache
|
||||
|
||||
|
||||
# ---- API Key 轮询(MX 备选源用)----
|
||||
|
||||
@@ -283,328 +281,6 @@ def get_eastmoney_market(code: str) -> str:
|
||||
return "1"
|
||||
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"}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
通过 TCP 协议直连通达信行情服务器,不走 HTTP,不会被限流。
|
||||
主要用途:
|
||||
- K线数据:主数据源(稳定可靠)
|
||||
- 板块数据:东方财富 push2 的降级方案
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -69,60 +68,3 @@ async def fetch_kline_history(code: str, days: int = 90) -> Optional[List[dict]]
|
||||
except Exception as e:
|
||||
print(f"[mootdx] fetch_kline error: {e}")
|
||||
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
|
||||
|
||||
@@ -88,9 +88,14 @@ def _next_open_delta_seconds() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# 盘中题材列表短缓存:题材热点页与热点穿透聚合共用同一份缓存,避免重复拉全量列表打东财。
|
||||
# 120s 长于热点穿透图缓存(60s),图重建时必然命中且更新频率更低,两页数据更稳。
|
||||
_LIST_CACHE_SECONDS = 120
|
||||
|
||||
|
||||
def _list_ttl_seconds() -> int:
|
||||
"""题材列表缓存秒数:交易时段 0(不缓存、实时拉取);非交易时段缓存到下次开盘前失效"""
|
||||
return 0 if _is_trading_time() else _next_open_delta_seconds()
|
||||
"""题材列表缓存秒数:交易时段 120s 短缓存;非交易时段缓存到下次开盘前失效"""
|
||||
return _LIST_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds()
|
||||
|
||||
|
||||
# ---- 请求封装 ----
|
||||
@@ -159,11 +164,10 @@ async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict]
|
||||
asc: True=升序, False=降序
|
||||
"""
|
||||
cache_key = f"theme_list:{sort_field}:{asc}"
|
||||
# 交易时段强制实时:跳过缓存读取,避免命中非交易时段写入的上个交易日旧数据
|
||||
if not _is_trading_time():
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
# 统一读缓存(盘中 TTL=120s 短缓存,非盘中缓存到下次开盘前失效),避免重复拉全量列表打东财
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
sort = 1 if asc else -1
|
||||
# hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序
|
||||
@@ -443,3 +447,56 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1
|
||||
data = await _build_theme_graph(sort_field, top_n)
|
||||
_set_graph_cache(cache_key, data)
|
||||
return _trim_graph_result(data, limit)
|
||||
|
||||
|
||||
# ---- 题材相关新闻(分页) ----
|
||||
|
||||
_NEWS_PAGE_SIZE = 10
|
||||
|
||||
|
||||
async def fetch_theme_news(theme_code: str, page_num: int = 1, max_eu_time: str = "", page_size: int = _NEWS_PAGE_SIZE) -> Optional[dict]:
|
||||
"""获取题材相关新闻(分页),返回 {total, maxEuTime, list}
|
||||
|
||||
maxEuTime 为游标:上一页返回的 maxEuTime 作为下一页入参,首页传空串。
|
||||
盘中 60s 短缓存(与图缓存同频);非盘中缓存到下次开盘前失效。
|
||||
"""
|
||||
cache_key = f"theme_news:{theme_code}:{page_num}:{max_eu_time}:{page_size}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
data = await _post(
|
||||
"/api/themeInvest/getThemeRelatedNews",
|
||||
{"themeCode": theme_code, "pageNum": page_num, "maxEuTime": max_eu_time, "pageSize": page_size},
|
||||
app_key=_APP_KEY_DETAIL,
|
||||
)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
ttl_s = _graph_ttl_seconds()
|
||||
if ttl_s > 0:
|
||||
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||
return data
|
||||
|
||||
|
||||
# ---- 单题材实时行情(强度/热度/涨幅) ----
|
||||
|
||||
async def fetch_theme_quote(theme_code: str) -> Optional[dict]:
|
||||
"""获取单题材实时行情(strengthValue/hotValue/f3),盘中 60s 短缓存"""
|
||||
cache_key = f"theme_quote:{theme_code}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
data = await _post(
|
||||
"/api/themeInvest/getSingleThemeQuote",
|
||||
{"themeCode": theme_code},
|
||||
app_key=_APP_KEY_DETAIL,
|
||||
)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
ttl_s = _graph_ttl_seconds()
|
||||
if ttl_s > 0:
|
||||
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||
return data
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 服务器端脚本:清理指定交易日旧数据,并用新选股逻辑重新采集入库。
|
||||
#
|
||||
# 适用场景:核心股追踪选股逻辑更新后,服务器上当天数据仍是旧口径,
|
||||
# 需要删掉重采(采集器幂等,不删会被 _has_collected 跳过)。
|
||||
#
|
||||
# 用法(在服务器上执行):
|
||||
# ./recollect-daily.sh [日期] # 日期默认取服务器当天,格式 YYYY-MM-DD
|
||||
# ./recollect-daily.sh 2026-08-10 # 显式指定交易日
|
||||
#
|
||||
# 依赖:docker 容器名为 auv(docker-compose.yml 中 container_name)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="${CONTAINER:-auv}"
|
||||
DATE="${1:-$(date +%F)}"
|
||||
|
||||
# 校验日期格式,防止注入/拼错
|
||||
if ! [[ "${DATE}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||
echo "❌ 日期格式错误:${DATE}(应为 YYYY-MM-DD)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "================================================"
|
||||
echo "🚀 容器: ${CONTAINER} 交易日: ${DATE}"
|
||||
echo "================================================"
|
||||
|
||||
# 确认容器在运行
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx "${CONTAINER}"; then
|
||||
echo "❌ 容器 ${CONTAINER} 未在运行"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "① 删除 ${DATE} 旧数据"
|
||||
# 通过环境变量传日期,避免日期值混入 python -c 的字符串拼接
|
||||
docker exec -e TARGET_DATE="${DATE}" "${CONTAINER}" python -c "
|
||||
import os
|
||||
from database import get_connection
|
||||
date = os.environ['TARGET_DATE']
|
||||
conn = get_connection()
|
||||
try:
|
||||
for tbl in ('daily_core_stocks', 'daily_core_stock_themes', 'daily_top_themes'):
|
||||
cur = conn.execute('DELETE FROM ' + tbl + ' WHERE trade_date = ?', (date,))
|
||||
print(f' {tbl}: 删除 {cur.rowcount} 行')
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "② 用新选股逻辑重采 ${DATE}"
|
||||
docker exec -e TARGET_DATE="${DATE}" "${CONTAINER}" python -c "
|
||||
import asyncio
|
||||
import os
|
||||
from services.daily_collector import collect_daily
|
||||
result = asyncio.run(collect_daily(os.environ['TARGET_DATE']))
|
||||
print(' 结果:', result)
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "✅ 完成"
|
||||
+3
-48
@@ -97,7 +97,9 @@ export interface BoardInfo {
|
||||
* - 8/4/920开头:北交所(BJB)
|
||||
* - 其他:主板
|
||||
*/
|
||||
export function getStockBoard(code: string): BoardInfo {
|
||||
export function getStockBoard(code: string | null | undefined): BoardInfo {
|
||||
// 题材列表等场景下 securityCode 可能为 null(无领涨股的题材),兜底为主板
|
||||
if (!code) return { board: "main", label: "", className: "" };
|
||||
if (code.startsWith("688")) {
|
||||
return { board: "kcb", label: "科", className: "bg-red-500/10 text-red-500 border-red-500/30" };
|
||||
}
|
||||
@@ -430,50 +432,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 [];
|
||||
}
|
||||
}
|
||||
|
||||
+71
-2
@@ -6,8 +6,8 @@ import { getApiBaseUrl } from "@/lib/api-client";
|
||||
export interface ThemeItem {
|
||||
themeCode: string;
|
||||
themeName: string;
|
||||
securityName: string; // 领涨股名称
|
||||
securityCode: string; // 领涨股代码
|
||||
securityName: string | null; // 领涨股名称(无领涨股的题材为 null)
|
||||
securityCode: string | null; // 领涨股代码(无领涨股的题材为 null)
|
||||
codeWithSuffix: string;
|
||||
hotRank: number; // 热度排名
|
||||
f3: number | null; // 领涨股涨幅
|
||||
@@ -159,6 +159,75 @@ export async function fetchThemeStocks(themeCode: string): Promise<ThemeStocksRe
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 题材相关新闻(分页) ── */
|
||||
|
||||
export interface ThemeNewsItem {
|
||||
newsCode: string;
|
||||
newsTitle: string;
|
||||
newsMediaName: string;
|
||||
showDateTime: number | null;
|
||||
showDateTimeFormat: string | null;
|
||||
commentCount: number;
|
||||
themeCode: string;
|
||||
themeName: string;
|
||||
}
|
||||
|
||||
export interface ThemeNewsResponse {
|
||||
total: number;
|
||||
maxEuTime: string;
|
||||
list: ThemeNewsItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题材相关新闻(分页,maxEuTime 为翻页游标)
|
||||
*/
|
||||
export async function fetchThemeNews(
|
||||
themeCode: string,
|
||||
pageNum: number = 1,
|
||||
pageSize: number = 10,
|
||||
maxEuTime: string = "",
|
||||
): Promise<ThemeNewsResponse | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/themes/${themeCode}/news?page_num=${pageNum}&page_size=${pageSize}&max_eu_time=${encodeURIComponent(maxEuTime)}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
const result = await resp.json();
|
||||
return result.data || null;
|
||||
} catch (err) {
|
||||
console.error("[theme-api] 获取题材新闻失败:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 单题材实时行情(强度/热度/涨幅) ── */
|
||||
|
||||
export interface ThemeQuote {
|
||||
strengthValue: number | null;
|
||||
hotValueUpLimit: number;
|
||||
hotValue: number;
|
||||
f3: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单题材实时行情
|
||||
*/
|
||||
export async function fetchThemeQuote(themeCode: string): Promise<ThemeQuote | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/themes/${themeCode}/quote`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
const result = await resp.json();
|
||||
return result.data || null;
|
||||
} catch (err) {
|
||||
console.error("[theme-api] 获取题材行情失败:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 热点穿透:题材-股票 网状关系图 ── */
|
||||
|
||||
export interface GraphTheme {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -815,9 +815,9 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
)
|
||||
.force("charge", forceManyBody<SimNode>().strength((d) => (d.type === "theme" ? -650 : -35)))
|
||||
.force("center", forceCenter(size.w / 2, size.h / 2))
|
||||
// 题材节点留出标签高度防文字重叠(标签在节点下方)
|
||||
// 题材节点留出标签高度防文字重叠(标签在节点下方);股票节点留 6px 最小间隔
|
||||
.force("collide", forceCollide<SimNode>().radius((d) =>
|
||||
d.type === "theme" ? d.radius + (isCoarse ? 20 : 16) : d.radius + 4,
|
||||
d.type === "theme" ? d.radius + (isCoarse ? 20 : 16) : d.radius + 6,
|
||||
));
|
||||
|
||||
simRunningRef.current = true;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+99
-24
@@ -1,7 +1,14 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchThemeDetail, fetchThemeStocks, type ThemeStock } from "@/lib/theme-api";
|
||||
import {
|
||||
fetchThemeDetail,
|
||||
fetchThemeNews,
|
||||
fetchThemeQuote,
|
||||
fetchThemeStocks,
|
||||
type ThemeStock,
|
||||
type ThemeNewsItem,
|
||||
} from "@/lib/theme-api";
|
||||
import { getStockBoard } from "@/lib/stock-api";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -36,6 +43,27 @@ function ThemeDetailPage() {
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
// 相关新闻:pageNum 偏移分页(东财接口 maxEuTime 是增量游标,翻页靠 pageNum 递增)
|
||||
const [newsPage, setNewsPage] = useState(1);
|
||||
const [newsItems, setNewsItems] = useState<ThemeNewsItem[]>([]);
|
||||
const newsQ = useQuery({
|
||||
queryKey: ["themeNews", code, newsPage],
|
||||
queryFn: () => fetchThemeNews(code, newsPage, 10),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
const quoteQ = useQuery({
|
||||
queryKey: ["themeQuote", code],
|
||||
queryFn: () => fetchThemeQuote(code),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// 分页追加:首页重置列表,翻页拼接
|
||||
useEffect(() => {
|
||||
if (!newsQ.data?.list) return;
|
||||
setNewsItems((prev) => (newsPage === 1 ? newsQ.data!.list : [...prev, ...newsQ.data!.list]));
|
||||
}, [newsQ.data, newsPage]);
|
||||
|
||||
const isLoading = detailQ.isLoading || stocksQ.isLoading;
|
||||
const isError = detailQ.isError || stocksQ.isError;
|
||||
@@ -49,11 +77,13 @@ function ThemeDetailPage() {
|
||||
const refresh = () => {
|
||||
detailQ.refetch();
|
||||
stocksQ.refetch();
|
||||
quoteQ.refetch();
|
||||
setNewsPage(1);
|
||||
newsQ.refetch();
|
||||
};
|
||||
|
||||
const baseInfo = detail?.baseInfo;
|
||||
const hotEvent = detail?.hotEvent;
|
||||
const eventHistory = detail?.eventHistory ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -139,10 +169,20 @@ function ThemeDetailPage() {
|
||||
flat={statistic?.f106}
|
||||
fex5={statistic?.fex5}
|
||||
total={total}
|
||||
strength={quoteQ.data?.strengthValue ?? null}
|
||||
hotValue={quoteQ.data?.hotValue ?? 0}
|
||||
hotValueUpLimit={quoteQ.data?.hotValueUpLimit ?? 0}
|
||||
/>
|
||||
|
||||
{/* ── 相关新闻(可折叠) ── */}
|
||||
{eventHistory.length > 0 && <NewsList items={eventHistory} />}
|
||||
{/* ── 相关新闻(分页加载) ── */}
|
||||
{newsItems.length > 0 && (
|
||||
<NewsList
|
||||
items={newsItems}
|
||||
total={newsQ.data?.total ?? 0}
|
||||
loadingMore={newsQ.isFetching && newsPage > 1}
|
||||
onLoadMore={() => setNewsPage((p) => p + 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 相关股票 ── */}
|
||||
<div>
|
||||
@@ -181,6 +221,9 @@ function StatBar({
|
||||
flat,
|
||||
fex5,
|
||||
total,
|
||||
strength,
|
||||
hotValue,
|
||||
hotValueUpLimit,
|
||||
}: {
|
||||
f3: number | null | undefined;
|
||||
up: number | null | undefined;
|
||||
@@ -188,8 +231,13 @@ function StatBar({
|
||||
flat: number | null | undefined;
|
||||
fex5: number | null | undefined;
|
||||
total: number;
|
||||
strength: number | null;
|
||||
hotValue: number;
|
||||
hotValueUpLimit: number;
|
||||
}) {
|
||||
const isPos = (f3 ?? 0) >= 0;
|
||||
const hotPct = hotValueUpLimit > 0 ? Math.min((hotValue / hotValueUpLimit) * 100, 100) : 0;
|
||||
const showQuote = strength != null || hotValueUpLimit > 0;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
@@ -218,24 +266,49 @@ function StatBar({
|
||||
平盘 {flat} 只
|
||||
</p>
|
||||
)}
|
||||
{/* 强度 + 热度(来自单题材实时行情接口) */}
|
||||
{showQuote && (
|
||||
<div className="mt-2 pt-2 border-t border-border/40 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
|
||||
{strength != null && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
强度
|
||||
<b className="font-bold text-foreground tabular-nums">{strength}</b>
|
||||
</span>
|
||||
)}
|
||||
{hotValueUpLimit > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Flame className="h-3 w-3 text-orange-500" />
|
||||
<span className="w-16 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<span
|
||||
className="block h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
|
||||
style={{ width: `${Math.max(hotPct, 2)}%` }}
|
||||
/>
|
||||
</span>
|
||||
热度 {hotValue}/{hotValueUpLimit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
相关新闻(可折叠)
|
||||
相关新闻(分页加载)
|
||||
============================================================ */
|
||||
function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string; newsPublishTime: number | null }[] }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const shown = expanded ? items : items.slice(0, 2);
|
||||
|
||||
const fmtTime = (ts: number | null) => {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
function NewsList({
|
||||
items,
|
||||
total,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: {
|
||||
items: ThemeNewsItem[];
|
||||
total: number;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
}) {
|
||||
const hasMore = items.length < total;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -243,26 +316,28 @@ function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Newspaper className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">相关新闻</h2>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">{items.length} 条</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">共 {total} 条</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{shown.map((n, idx) => (
|
||||
{items.map((n, idx) => (
|
||||
<div key={idx} className="space-y-0.5">
|
||||
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{n.newsMediaName}
|
||||
{n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""}
|
||||
{n.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""}
|
||||
{n.commentCount > 0 && <span className="ml-1">· {n.commentCount} 评论</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{items.length > 2 && (
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5"
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5 disabled:opacity-50"
|
||||
>
|
||||
{expanded ? "收起" : `展开全部 ${items.length} 条`}
|
||||
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{loadingMore ? "加载中…" : "加载更多"}
|
||||
{!loadingMore && <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// stub:getStockBoard 测试用,仅用于解析 @/lib/api-client 别名(CommonJS)
|
||||
function getApiBaseUrl() {
|
||||
return "http://localhost:8000";
|
||||
}
|
||||
module.exports = { getApiBaseUrl };
|
||||
@@ -0,0 +1,63 @@
|
||||
// 最小复现/验证脚本:直接编译 src/lib/stock-api.ts 真实源码,
|
||||
// 调用 getStockBoard(null),复现「Cannot read properties of null (reading 'startsWith')」。
|
||||
// 无测试框架,node 直接运行:node tests/get-stock-board-repro.mjs
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Module from "node:module";
|
||||
import ts from "typescript";
|
||||
|
||||
const srcPath = path.resolve("src/lib/stock-api.ts");
|
||||
const source = fs.readFileSync(srcPath, "utf8");
|
||||
|
||||
const js = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true },
|
||||
}).outputText;
|
||||
|
||||
const mod = new Module(srcPath);
|
||||
mod.filename = srcPath;
|
||||
mod.paths = Module._nodeModulePaths(path.dirname(srcPath));
|
||||
|
||||
// 拦截 @/lib/api-client 别名导入(getStockBoard 本身不依赖它)
|
||||
const origResolve = Module._resolveFilename;
|
||||
Module._resolveFilename = function (request, ...args) {
|
||||
if (request === "@/lib/api-client") return path.resolve("tests/_stub-api-client.cjs");
|
||||
return origResolve.call(this, request, ...args);
|
||||
};
|
||||
|
||||
try {
|
||||
mod._compile(js, srcPath);
|
||||
} finally {
|
||||
Module._resolveFilename = origResolve;
|
||||
}
|
||||
|
||||
const { getStockBoard } = mod.exports;
|
||||
|
||||
// ---- 断言 ----
|
||||
function assertThrows(fn, label) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✗ ${label}: 未抛错`);
|
||||
process.exitCode = 1;
|
||||
} catch (e) {
|
||||
console.log(`✓ ${label}: 抛错 -> ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoThrow(fn, label) {
|
||||
try {
|
||||
const r = fn();
|
||||
console.log(`✓ ${label}: 未抛错 -> ${JSON.stringify(r)}`);
|
||||
return r;
|
||||
} catch (e) {
|
||||
console.log(`✗ ${label}: 抛错 -> ${e.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 正常代码
|
||||
assertNoThrow(() => getStockBoard("600000"), "正常代码 getStockBoard('600000')");
|
||||
// 崩盘场景:securityCode 为 null(East Money 题材列表实测存在),修复后应兜底返回主板
|
||||
assertNoThrow(() => getStockBoard(null), "null securityCode");
|
||||
assertNoThrow(() => getStockBoard(undefined), "undefined securityCode");
|
||||
// 空串不崩
|
||||
assertNoThrow(() => getStockBoard(""), "空串 getStockBoard('')");
|
||||
Reference in New Issue
Block a user