Compare commits
73
Commits
62961017ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48522cbfea | ||
|
|
a6497caf73 | ||
|
|
8cf4c40e2b | ||
|
|
90569918a3 | ||
|
|
b0dbeef3fd | ||
|
|
eb66ab02d0 | ||
|
|
7fe8074e22 | ||
|
|
f98a9255a5 | ||
|
|
776ab55fc3 | ||
|
|
dfcce3abaf | ||
|
|
d473bbad6b | ||
|
|
c4a0c50ed0 | ||
|
|
0a32bfad78 | ||
|
|
69535bc782 | ||
|
|
254deb6a80 | ||
|
|
b29ca8413c | ||
|
|
c610faa4ad | ||
|
|
f93ffd381b | ||
|
|
1d3ec3194c | ||
|
|
b4d48180b7 | ||
|
|
6d3c470b7b | ||
|
|
23644a9c77 | ||
|
|
1c434a208a | ||
|
|
036a87bac9 | ||
|
|
258fc9184d | ||
|
|
ac985a2140 | ||
|
|
a7cff0930d | ||
|
|
5e7f0c4f13 | ||
|
|
77f6e36c13 | ||
|
|
cd56f6c158 | ||
|
|
c58bd990fd | ||
|
|
6b12b04cab | ||
|
|
ffabf3d396 | ||
|
|
f5ba61e3db | ||
|
|
526b182051 | ||
|
|
14c31e3f06 | ||
|
|
aed3eea739 | ||
|
|
5ffa5c07b2 | ||
|
|
84d237de1f | ||
|
|
f354da108d | ||
|
|
8687bda331 | ||
|
|
75c7ff3670 | ||
|
|
84a01fb7b1 | ||
|
|
ce131b2867 | ||
|
|
1550291d6b | ||
|
|
a1736c8a17 | ||
|
|
548ebee47f | ||
|
|
e28d1a2fd0 | ||
|
|
45836f4a30 | ||
|
|
b107a12798 | ||
|
|
28ecc4d138 | ||
|
|
2a0960c251 | ||
|
|
b67d07e977 | ||
|
|
ea3da4e138 | ||
|
|
bd1df35456 | ||
|
|
22692be45e | ||
|
|
eea2ce86d0 | ||
|
|
94d84a368f | ||
|
|
3bfeb81c90 | ||
|
|
ef795c46b2 | ||
|
|
677482639d | ||
|
|
65013dad3e | ||
|
|
2db6bb8519 | ||
|
|
fd00ce11d6 | ||
|
|
1da4f8f479 | ||
|
|
20b2afb0fc | ||
|
|
aaa618b15d | ||
|
|
60eff4a955 | ||
|
|
86535f5de2 | ||
|
|
015aeb989a | ||
|
|
1a50f7680a | ||
|
|
e29e3d32fb | ||
|
|
c6cef8bb4c |
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 "$@"
|
||||
@@ -37,6 +37,40 @@ CREATE TABLE IF NOT EXISTS cache (
|
||||
value TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_core_stocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
stock_code TEXT NOT NULL,
|
||||
stock_name TEXT NOT NULL,
|
||||
f3 REAL,
|
||||
cover_count INTEGER,
|
||||
rank INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_core_stock_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
stock_code TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code, theme_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_top_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
bf3 REAL,
|
||||
hot_rank INTEGER,
|
||||
rank INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, theme_code)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+17
-3
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -6,7 +7,8 @@ from contextlib import asynccontextmanager
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from database import init_db
|
||||
from routes import stock, collections, shares, sectors
|
||||
from routes import stock, collections, shares, themes, core_stocks
|
||||
from services.daily_collector import collector_loop, cache_cleanup_loop
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -14,7 +16,18 @@ load_dotenv()
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
collector_task = asyncio.create_task(collector_loop())
|
||||
cache_cleanup_task = asyncio.create_task(cache_cleanup_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
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)
|
||||
@@ -30,7 +43,8 @@ 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")
|
||||
|
||||
# 生产模式:后端同时托管前端静态文件
|
||||
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
||||
|
||||
@@ -2,4 +2,5 @@ fastapi==0.115.0
|
||||
uvicorn==0.30.0
|
||||
httpx==0.27.0
|
||||
python-dotenv==1.0.1
|
||||
akshare==1.18.64
|
||||
akshare==1.18.64
|
||||
mootdx
|
||||
@@ -0,0 +1,117 @@
|
||||
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
|
||||
|
||||
from datetime import date as date_cls
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from database import get_connection, dict_from_row
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||
|
||||
|
||||
def _recent_trade_dates(conn, n: int = 10) -> list[str]:
|
||||
"""最近 n 个有数据的交易日(升序)"""
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
|
||||
(n,),
|
||||
).fetchall()
|
||||
return [r["trade_date"] for r in reversed(rows)]
|
||||
|
||||
|
||||
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
|
||||
async def active_core_stocks():
|
||||
conn = get_connection()
|
||||
try:
|
||||
dates = _recent_trade_dates(conn, 10)
|
||||
if not dates:
|
||||
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
# 窗口内出现过且最近一次出现距今天数 <= 10 个交易日
|
||||
placeholders = ",".join("?" * len(dates))
|
||||
rows = conn.execute(
|
||||
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count FROM daily_core_stocks
|
||||
WHERE trade_date IN ({placeholders})
|
||||
ORDER BY trade_date DESC, rank ASC""",
|
||||
dates,
|
||||
).fetchall()
|
||||
|
||||
# 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜
|
||||
stock_days: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
code = r["stock_code"]
|
||||
s = stock_days.setdefault(code, {
|
||||
"stockCode": code,
|
||||
"stockName": r["stock_name"],
|
||||
"coverCount": r["cover_count"],
|
||||
"dailyGains": {},
|
||||
"appearCount": 0,
|
||||
"lastAppear": None,
|
||||
})
|
||||
s["dailyGains"][r["trade_date"]] = r["f3"]
|
||||
s["appearCount"] += 1
|
||||
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
|
||||
s["lastAppear"] = r["trade_date"]
|
||||
|
||||
stocks = list(stock_days.values())
|
||||
# 稳定排序:先按出现次数降序,再按最近上榜日降序
|
||||
stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True)
|
||||
stocks.sort(key=lambda x: -x["appearCount"])
|
||||
|
||||
# 一次性取窗口内全部题材关联,按 stock_code 分组(跨天题材去重)
|
||||
themes_rows = conn.execute(
|
||||
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
|
||||
WHERE trade_date IN ({placeholders})""",
|
||||
dates,
|
||||
).fetchall()
|
||||
themes_by_stock: dict[str, dict[str, dict]] = {}
|
||||
for t in themes_rows:
|
||||
per = themes_by_stock.setdefault(t["stock_code"], {})
|
||||
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
|
||||
for s in stocks:
|
||||
s["themes"] = list(themes_by_stock.get(s["stockCode"], {}).values())
|
||||
|
||||
# daysSinceLastAppear:最近上榜距窗口最新交易日的自然日差(简单口径)
|
||||
latest = dates[-1] if dates else None
|
||||
for s in stocks:
|
||||
if s.get("lastAppear") and latest:
|
||||
try:
|
||||
d1 = date_cls.fromisoformat(latest)
|
||||
d2 = date_cls.fromisoformat(s["lastAppear"])
|
||||
s["daysSinceLastAppear"] = (d1 - d2).days
|
||||
except ValueError:
|
||||
s["daysSinceLastAppear"] = 0
|
||||
else:
|
||||
s["daysSinceLastAppear"] = 0
|
||||
|
||||
return JSONResponse({"dates": dates, "stocks": stocks}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/history", summary="指定交易日核心股(含所属题材)")
|
||||
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC", (date,)
|
||||
).fetchall()
|
||||
# 一次性取该日全部题材关联,按 stock_code 分组,避免逐股 N+1 查询
|
||||
themes_rows = conn.execute(
|
||||
"SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ?",
|
||||
(date,),
|
||||
).fetchall()
|
||||
themes_by_stock: dict[str, list] = {}
|
||||
for t in themes_rows:
|
||||
themes_by_stock.setdefault(t["stock_code"], []).append(
|
||||
{"theme_code": t["theme_code"], "theme_name": t["theme_name"]}
|
||||
)
|
||||
items = []
|
||||
for r in rows:
|
||||
d = dict_from_row(r)
|
||||
d["themes"] = themes_by_stock.get(d["stock_code"], [])
|
||||
items.append(d)
|
||||
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1,17 +0,0 @@
|
||||
"""板块数据路由:行业板块、概念板块"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from services import eastmoney
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@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)
|
||||
return {"data": data, "count": len(data), "type": type}
|
||||
+37
-6
@@ -3,7 +3,7 @@
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
import re
|
||||
|
||||
from services import tencent, sina, eastmoney
|
||||
from services import tencent, sina, eastmoney, mootdx
|
||||
from models import StockSearchResult, StockQuote, KLineData, FundFlowData, FundFlowSummary, CompanyProfile, FinancialReportItem, FinancialDataResponse
|
||||
|
||||
router = APIRouter()
|
||||
@@ -54,21 +54,28 @@ async def stock_history(
|
||||
if not re.match(r"^\d{6}$", code):
|
||||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||||
|
||||
# 主数据源:东方财富 push2his(含成交额/涨跌幅/振幅/换手率,可能被限流)
|
||||
# 主数据源:通达信 mootdx(TCP直连,不被限流,稳定可靠)
|
||||
md_klines = await mootdx.fetch_kline_history(code, days)
|
||||
if md_klines and len(md_klines) >= 2:
|
||||
return {"data": md_klines, "count": len(md_klines), "source": "mootdx"}
|
||||
|
||||
# 降级1:东方财富 push2his(含成交额/涨跌幅/振幅/换手率)
|
||||
em_klines = await eastmoney.fetch_kline_history(code, days)
|
||||
if em_klines and len(em_klines) >= 2:
|
||||
return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"}
|
||||
|
||||
# 降级1:腾讯(含涨跌幅)
|
||||
# 降级2:腾讯(含涨跌幅)
|
||||
tencent_klines = await tencent.fetch_history(code, days)
|
||||
if tencent_klines and len(tencent_klines) >= 2:
|
||||
return {"data": tencent_klines, "count": len(tencent_klines), "source": "tencent"}
|
||||
|
||||
# 降级2:新浪
|
||||
# 降级3:新浪
|
||||
sina_klines = await sina.fetch_history(code, days)
|
||||
if sina_klines:
|
||||
return {"data": sina_klines, "count": len(sina_klines), "source": "sina"}
|
||||
|
||||
if md_klines:
|
||||
return {"data": md_klines, "count": len(md_klines), "source": "mootdx"}
|
||||
if em_klines:
|
||||
return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"}
|
||||
if tencent_klines:
|
||||
@@ -194,12 +201,20 @@ async def stock_fund_flow(
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="未获取到资金流向数据")
|
||||
|
||||
# 从腾讯 K 线补充成交额/收盘价/涨跌幅
|
||||
# 从腾讯 K 线补充收盘价/涨跌幅
|
||||
kline_map = await tencent.fetch_kline_map(code, days)
|
||||
# 从东方财富 K 线补充成交额(腾讯 fqkline 不含成交额字段)
|
||||
em_klines = await eastmoney.fetch_kline_history(code, days)
|
||||
em_kline_map = {}
|
||||
if em_klines:
|
||||
for k in em_klines:
|
||||
if k.get("turnover"):
|
||||
em_kline_map[k["date"]] = k["turnover"]
|
||||
for d in data:
|
||||
ki = kline_map.get(d["date"], {})
|
||||
if d.get("turnover", 0) == 0:
|
||||
d["turnover"] = ki.get("turnover", 0)
|
||||
# 优先从东方财富 kline 拿成交额,其次腾讯
|
||||
d["turnover"] = em_kline_map.get(d["date"], ki.get("turnover", 0))
|
||||
if d.get("closePrice", 0) == 0:
|
||||
d["closePrice"] = ki.get("close", 0)
|
||||
if d.get("changePercent", 0) == 0:
|
||||
@@ -238,3 +253,19 @@ async def stock_fund_flow(
|
||||
"negativeDays": negative,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/mx-tool", summary="MX 通用金融数据查询")
|
||||
async def mx_tool(
|
||||
query: str = Query(..., description="自然语言问句,如:「格力电器2024年净利润」「沪深300最新收盘价」「市盈率最低的50只股票」"),
|
||||
):
|
||||
"""通过 MX 妙想 API 查询任意金融数据(A股/港股/美股/基金/债券/指数/板块/宏观/新闻/公告/选股等)
|
||||
|
||||
单次查询最多支持 20 只证券。返回原始结构化表格(sheetName + columns + items)。
|
||||
"""
|
||||
if not query or not query.strip():
|
||||
raise HTTPException(status_code=400, detail="查询内容不能为空")
|
||||
data = await eastmoney.fetch_mx_tool(query.strip())
|
||||
if not data:
|
||||
raise HTTPException(status_code=404, detail="MX 查询无结果或所有 API Key 已耗尽")
|
||||
return {"data": data}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""题材数据路由:题材列表、题材详情、题材相关股票"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from database import get_connection, dict_from_row
|
||||
from services import themes
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 上游反代/CDN 可能按 path 缓存,显式禁止缓存
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||
|
||||
|
||||
@router.get("", summary="题材列表")
|
||||
async def theme_list(
|
||||
sort_field: int = Query(1, ge=1, le=5, description="排序字段:1=涨幅 3=强度 4=热度排名 5=成交额"),
|
||||
asc: bool = Query(False, description="True=升序, False=降序"),
|
||||
):
|
||||
if sort_field not in (1, 3, 4, 5):
|
||||
raise HTTPException(status_code=400, detail="排序字段仅支持 1/3/4/5")
|
||||
|
||||
data = await themes.fetch_theme_list(sort_field, asc)
|
||||
return JSONResponse(
|
||||
{"data": data, "count": len(data), "sort_field": sort_field, "asc": asc},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/graph", summary="热点穿透:题材-股票网状关系图")
|
||||
async def theme_graph(
|
||||
sort_field: int = Query(1, description="题材排序:1=涨幅 4=热度"),
|
||||
top: int = Query(30, ge=1, le=60, description="题材数量"),
|
||||
limit: int = Query(1000, ge=100, le=2000, description="下发的股票节点上限(按穿透度取前 N 只)"),
|
||||
):
|
||||
if sort_field not in (1, 4):
|
||||
raise HTTPException(status_code=400, detail="排序字段仅支持 1(涨幅)/4(热度)")
|
||||
|
||||
result = await themes.fetch_theme_graph(sort_field, top, limit)
|
||||
return JSONResponse(result, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/history", summary="指定交易日题材涨幅前20")
|
||||
async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC", (date,)
|
||||
).fetchall()
|
||||
items = [dict_from_row(r) for r in rows]
|
||||
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
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)
|
||||
if not data:
|
||||
return JSONResponse(
|
||||
{"data": None, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"data": data, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{theme_code}/stocks", summary="题材相关股票(全部)")
|
||||
async def theme_stocks(theme_code: str):
|
||||
result = await themes.fetch_theme_stocks(theme_code)
|
||||
return JSONResponse(
|
||||
{
|
||||
"data": result.get("stockList", []),
|
||||
"statistic": result.get("statistic", {}),
|
||||
"total": result.get("total", 0),
|
||||
"theme_code": theme_code,
|
||||
},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
@@ -23,9 +23,9 @@ def get_cache(key: str) -> Optional[str]:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_cache(key: str, value: str, ttl_hours: int = 6):
|
||||
"""写入缓存,过期时间 = now + ttl_hours"""
|
||||
expires_at = (datetime.now() + timedelta(hours=ttl_hours)).isoformat()
|
||||
def set_cache(key: str, value: str, ttl_hours: int = 6, ttl_seconds: int = 0):
|
||||
"""写入缓存,过期时间 = now + ttl_hours + ttl_seconds(支持秒级短 TTL)"""
|
||||
expires_at = (datetime.now() + timedelta(hours=ttl_hours, seconds=ttl_seconds)).isoformat()
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
|
||||
|
||||
由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
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, 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:
|
||||
"""仅按工作日判断:周一至周五视为交易日,不处理法定节假日"""
|
||||
return d.weekday() < 5
|
||||
|
||||
|
||||
def _has_collected(trade_date: str) -> bool:
|
||||
"""当日核心股是否已采集"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
|
||||
(trade_date,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def collect_daily(trade_date: str, dry_run: bool = False) -> dict:
|
||||
"""采集指定交易日数据并入库。
|
||||
|
||||
核心股 = 题材领涨股涨幅前100 ∪ 热点穿透核心股(覆盖题材数≥2、按覆盖数降序前100),按股票代码去重。
|
||||
热点穿透构建失败时降级为仅领涨股前100,不影响当日采集。
|
||||
|
||||
Args:
|
||||
trade_date: YYYY-MM-DD
|
||||
dry_run: True 只打印不写库(用于验证)
|
||||
|
||||
Returns:
|
||||
{"core_count": int, "theme_count": int, "skipped": bool}
|
||||
"""
|
||||
if _has_collected(trade_date):
|
||||
print(f"[collector] {trade_date} 已采集,跳过")
|
||||
return {"core_count": 0, "theme_count": 0, "skipped": True}
|
||||
|
||||
# 1. 拉取全部题材列表(含领涨股,作为当日全部股票的采样来源)
|
||||
themes = await fetch_theme_list(1, False)
|
||||
if not themes:
|
||||
print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过")
|
||||
return {"core_count": 0, "theme_count": 0, "skipped": True}
|
||||
|
||||
# 领涨题材映射:securityCode -> [(themeCode, themeName), ...](用于核心股"所属题材")
|
||||
lead_themes: dict[str, list[tuple[str, str]]] = {}
|
||||
for t in themes:
|
||||
code = t.get("securityCode")
|
||||
if not code:
|
||||
continue
|
||||
lead_themes.setdefault(code, []).append((t["themeCode"], t["themeName"]))
|
||||
|
||||
# 2. 热点穿透核心股:覆盖题材数≥2,按覆盖题材数降序前100(去重)
|
||||
# _build_theme_graph 采样 涨幅榜+热度榜 各前 HOTMAP_TOP_N 个题材并拉每股覆盖题材数,
|
||||
# 已按 (-coverCount, -f3) 降序;失败时降级为空集,仅保留领涨股。
|
||||
hotmap_core: list[dict] = []
|
||||
graph_theme_name: dict[str, str] = {}
|
||||
try:
|
||||
graph = await _build_theme_graph(1, HOTMAP_TOP_N)
|
||||
graph_theme_name = {t["themeCode"]: t["themeName"] for t in graph.get("themes", [])}
|
||||
hotmap_core = [
|
||||
s for s in graph.get("stocks", [])
|
||||
if s.get("coverCount", 0) >= CORE_COVER_THRESHOLD
|
||||
][:HOTMAP_CORE_LIMIT]
|
||||
except Exception as e:
|
||||
print(f"[collector] 热点穿透核心股构建失败,降级为仅领涨股: {e}")
|
||||
|
||||
# 3. 题材领涨股涨幅前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股)
|
||||
stock_map: dict[str, dict] = {}
|
||||
theme_count: dict[str, int] = {} # securityCode -> 领涨题材数
|
||||
for t in themes:
|
||||
code = t.get("securityCode")
|
||||
if not code:
|
||||
continue
|
||||
theme_count[code] = theme_count.get(code, 0) + 1
|
||||
if code not in stock_map or (t.get("f3") or 0) > (stock_map[code].get("f3") or 0):
|
||||
stock_map[code] = {
|
||||
"stock_code": code,
|
||||
"stock_name": t.get("securityName", ""),
|
||||
"f3": t.get("f3"),
|
||||
}
|
||||
f3_core = sorted(stock_map.values(), key=lambda x: -(x["f3"] or 0))[:CORE_STOCK_LIMIT]
|
||||
|
||||
# 4. 题材涨幅前20:bf3 降序
|
||||
top_themes = sorted(themes, key=lambda x: -(x.get("bf3") or 0))[:TOP_THEME_LIMIT]
|
||||
|
||||
# 5. 合并去重:热点穿透核心股在前(覆盖数降序,rank 优先),随后补领涨股涨幅前100
|
||||
core_stocks: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
theme_pairs: set[tuple[str, str, str]] = set() # (stock_code, theme_code, theme_name)
|
||||
|
||||
for s in hotmap_core:
|
||||
code = s["securityCode"]
|
||||
seen.add(code)
|
||||
core_stocks.append({
|
||||
"stock_code": code,
|
||||
"stock_name": s.get("securityName", ""),
|
||||
"f3": s.get("f3"),
|
||||
"cover_count": s.get("coverCount", 0),
|
||||
})
|
||||
# 所属题材:采样榜内覆盖的题材 + 全量题材列表中的领涨题材
|
||||
for tc in s.get("themeCodes", []):
|
||||
theme_pairs.add((code, tc, graph_theme_name.get(tc, tc)))
|
||||
for tc, tn in lead_themes.get(code, []):
|
||||
theme_pairs.add((code, tc, tn))
|
||||
|
||||
for s in f3_core:
|
||||
code = s["stock_code"]
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
core_stocks.append({
|
||||
"stock_code": code,
|
||||
"stock_name": s["stock_name"],
|
||||
"f3": s["f3"],
|
||||
"cover_count": theme_count.get(code, 0),
|
||||
})
|
||||
for tc, tn in lead_themes.get(code, []):
|
||||
theme_pairs.add((code, tc, tn))
|
||||
|
||||
if dry_run:
|
||||
print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只(热点穿透 {len(hotmap_core)} + 领涨股 {len(f3_core)}),题材前20 {len(top_themes)} 只")
|
||||
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
|
||||
|
||||
# 6. 入库(事务,UNIQUE 幂等)
|
||||
conn = get_connection()
|
||||
try:
|
||||
for i, s in enumerate(core_stocks, start=1):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, cover_count, rank) VALUES (?,?,?,?,?,?)",
|
||||
(trade_date, s["stock_code"], s["stock_name"], s["f3"], s["cover_count"], i),
|
||||
)
|
||||
for code, tc, tn in theme_pairs:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)",
|
||||
(trade_date, code, tc, tn),
|
||||
)
|
||||
for i, t in enumerate(top_themes, start=1):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
|
||||
(trade_date, t["themeCode"], t["themeName"], t.get("bf3"), t.get("hotRank"), i),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(f"[collector] {trade_date} 已采集:核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)} 只")
|
||||
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
|
||||
|
||||
|
||||
async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
|
||||
"""后台循环:每个交易日 15:01 后自动采集当日数据(幂等)"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(_CST)
|
||||
if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME:
|
||||
trade_date = now.strftime("%Y-%m-%d")
|
||||
if not _has_collected(trade_date):
|
||||
await collect_daily(trade_date)
|
||||
except Exception:
|
||||
print("[collector] 采集异常:")
|
||||
traceback.print_exc()
|
||||
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 分钟后再试
|
||||
+87
-248
@@ -204,6 +204,75 @@ async def fetch_mx_api(name: str, days: int) -> Optional[List[dict]]:
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_mx_tool(tool_query: str) -> Optional[dict]:
|
||||
"""通用 MX 妙想 API 查询(自然语言 → 结构化数据,缓存 6 小时)
|
||||
|
||||
支持东方财富数据库的全品类查询,包括但不限于:
|
||||
- A 股/港股/美股行情、财务、估值、股本、事件
|
||||
- 基金净值、收益、持仓、排名
|
||||
- 债券基本信息、久期凸性、信用评级
|
||||
- 指数与板块行情、技术指标
|
||||
- 宏观经济/行业经济/大宗商品数据
|
||||
- 新闻研报、公告搜索
|
||||
- 多条件选股/选基/选债
|
||||
|
||||
Args:
|
||||
tool_query: 自然语言问句,如 "格力电器2024年营业收入和净利润"
|
||||
"沪深300最新收盘价" "市盈率最低的50只股票"
|
||||
|
||||
Returns:
|
||||
MX API 原始 JSON(status=0 时成功,data 中含 sheetName/columns/items)
|
||||
失败返回 None
|
||||
"""
|
||||
cache_key = f"mx_tool:{tool_query}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
if not _ensure_keys():
|
||||
return None
|
||||
|
||||
url = "https://mkapi2.dfcfs.com/finskillshub/api/claw/query"
|
||||
payload = {"toolQuery": tool_query}
|
||||
|
||||
for attempt in range(len(_api_keys)):
|
||||
key = _get_key()
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json", "apikey": key},
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
_rotate_key()
|
||||
continue
|
||||
result = resp.json()
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] MX tool error: {e}")
|
||||
_rotate_key()
|
||||
continue
|
||||
|
||||
status = result.get("status", -1)
|
||||
if status == 113:
|
||||
print(f"[eastmoney] key 已达每日上限,切换到下一个")
|
||||
_rotate_key()
|
||||
continue
|
||||
if status == 114:
|
||||
_rotate_key()
|
||||
continue
|
||||
if status != 0:
|
||||
_rotate_key()
|
||||
continue
|
||||
|
||||
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_hours=6)
|
||||
return result
|
||||
|
||||
print(f"[eastmoney] 所有 MX API key 均已耗尽")
|
||||
return None
|
||||
|
||||
|
||||
def get_eastmoney_market(code: str) -> str:
|
||||
"""获取东方财富格式的市场标识"""
|
||||
if code.startswith("688"):
|
||||
@@ -212,250 +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
|
||||
|
||||
|
||||
# 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="chrome120",
|
||||
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",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
return _sector_session
|
||||
|
||||
|
||||
# 内存缓存
|
||||
_sector_cache: dict[str, tuple[list[dict], float]] = {}
|
||||
_SECTOR_CACHE_TTL = 60
|
||||
|
||||
|
||||
async def fetch_sector_list(sector_type: str) -> list[dict]:
|
||||
now = time.time()
|
||||
if sector_type in _sector_cache:
|
||||
data, ts = _sector_cache[sector_type]
|
||||
if now - ts < _SECTOR_CACHE_TTL:
|
||||
return data
|
||||
|
||||
data = await _fetch_push2(sector_type)
|
||||
if data:
|
||||
_sector_cache[sector_type] = (data, now)
|
||||
return data
|
||||
|
||||
data = await _fetch_akshare(sector_type)
|
||||
if data:
|
||||
_sector_cache[sector_type] = (data, now)
|
||||
return 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()
|
||||
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}"
|
||||
)
|
||||
|
||||
for attempt in range(2):
|
||||
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
|
||||
# 重建会话(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()
|
||||
|
||||
def _get():
|
||||
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
|
||||
|
||||
if sector_type == "industry":
|
||||
df = ak.stock_fund_flow_industry()
|
||||
else:
|
||||
df = ak.stock_fund_flow_concept()
|
||||
|
||||
return code_map, df
|
||||
|
||||
try:
|
||||
code_map, df = await loop.run_in_executor(None, _get)
|
||||
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"}
|
||||
@@ -886,7 +711,6 @@ async def fetch_kline_history(code: str, days: int = 90) -> Optional[list[dict]]
|
||||
"turnoverRate": _f(10),
|
||||
})
|
||||
|
||||
parsed.reverse()
|
||||
return parsed
|
||||
except Exception as e:
|
||||
if attempt == 0:
|
||||
@@ -915,16 +739,31 @@ async def fetch_fund_flow_daykline(code: str, days: int) -> Optional[list[dict]]
|
||||
"Referer": "https://quote.eastmoney.com/",
|
||||
}
|
||||
|
||||
from curl_cffi.requests import AsyncSession
|
||||
|
||||
for attempt in range(2):
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with AsyncSession(impersonate="chrome120") as session:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
resp = await session.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
if attempt == 0:
|
||||
continue
|
||||
return None
|
||||
result = resp.json()
|
||||
klines = result.get("data", {}).get("klines", [])
|
||||
if not isinstance(result, dict):
|
||||
if attempt == 0:
|
||||
continue
|
||||
return None
|
||||
if result.get("rc", -1) != 0:
|
||||
if attempt == 0:
|
||||
continue
|
||||
return None
|
||||
data = result.get("data")
|
||||
if not data or not isinstance(data, dict):
|
||||
if attempt == 0:
|
||||
continue
|
||||
return None
|
||||
klines = data.get("klines", [])
|
||||
if not klines:
|
||||
if attempt == 0:
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""通达信数据源(mootdx TCP直连通达信服务器)
|
||||
|
||||
通过 TCP 协议直连通达信行情服务器,不走 HTTP,不会被限流。
|
||||
主要用途:
|
||||
- K线数据:主数据源(稳定可靠)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
def _get_market(code: str) -> str:
|
||||
return "sh" if code.startswith("6") else "sz"
|
||||
|
||||
|
||||
def _create_client():
|
||||
from mootdx.quotes import Quotes
|
||||
|
||||
return Quotes.factory(market="std", multithread=True, heartbeat=True)
|
||||
|
||||
|
||||
def _sync_fetch_kline(code: str, days: int) -> Optional[list]:
|
||||
client = _create_client()
|
||||
|
||||
klines = client.bars(symbol=code, frequency=9, offset=min(days, 800))
|
||||
if klines is None or len(klines) == 0:
|
||||
return None
|
||||
|
||||
result = []
|
||||
prev_close = 0.0
|
||||
for bar in reversed(klines):
|
||||
close = float(bar.close)
|
||||
change_pct = 0
|
||||
if prev_close > 0:
|
||||
change_pct = (close - prev_close) / prev_close * 100
|
||||
|
||||
result.append(
|
||||
{
|
||||
"date": (
|
||||
bar.datetime.strftime("%Y-%m-%d")
|
||||
if hasattr(bar.datetime, "strftime")
|
||||
else str(bar.datetime)[:10]
|
||||
),
|
||||
"open": float(bar.open),
|
||||
"close": close,
|
||||
"high": float(bar.high),
|
||||
"low": float(bar.low),
|
||||
"volume": int(bar.vol) if hasattr(bar, "vol") else 0,
|
||||
"turnover": (
|
||||
float(bar.amount) if hasattr(bar, "amount") and bar.amount else 0
|
||||
),
|
||||
"changePercent": round(change_pct, 2),
|
||||
}
|
||||
)
|
||||
prev_close = close
|
||||
|
||||
result.sort(key=lambda x: x["date"])
|
||||
return result
|
||||
|
||||
|
||||
async def fetch_kline_history(code: str, days: int = 90) -> Optional[List[dict]]:
|
||||
"""获取日K线(TCP直连通达信,主数据源)"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _sync_fetch_kline, code, days)
|
||||
except ImportError:
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[mootdx] fetch_kline error: {e}")
|
||||
return None
|
||||
@@ -142,10 +142,11 @@ async def fetch_quote(code: str) -> Optional[dict]:
|
||||
return None
|
||||
text = resp.content.decode("gbk", errors="replace")
|
||||
parts = parse_tencent_data(text)
|
||||
if not parts or len(parts) < 38:
|
||||
if not parts or len(parts) < 47:
|
||||
return None
|
||||
# 字段索引(1-based):1=名称, 3=当前价, 4=昨收, 5=今开, 6=成交量(手)
|
||||
# 字段索引:1=名称, 3=当前价, 4=昨收, 5=今开, 6=成交量(手)
|
||||
# 7=外盘, 8=内盘, 31=涨跌额, 32=涨跌幅%, 33=最高, 34=最低, 37=成交额(万)
|
||||
# 38=换手率%, 39=市盈率, 43=振幅%, 44=流通市值(亿), 45=总市值(亿), 46=市净率
|
||||
name = parts[1] or ""
|
||||
current_price = float(parts[3]) if parts[3] else 0
|
||||
yesterday_close = float(parts[4]) if parts[4] else 0
|
||||
@@ -158,6 +159,12 @@ async def fetch_quote(code: str) -> Optional[dict]:
|
||||
change_pct = float(parts[32]) if parts[32] else 0
|
||||
outer_disk = float(parts[7]) if parts[7] else 0
|
||||
inner_disk = float(parts[8]) if parts[8] else 0
|
||||
turnover_rate = float(parts[38]) if parts[38] else 0 # 换手率%
|
||||
pe = float(parts[39]) if parts[39] else 0 # 市盈率
|
||||
# 腾讯API返回的市值单位是亿, formatMoney期望元, 需×1e8
|
||||
total_market_cap = float(parts[45]) * 1e8 if parts[45] else 0 # 总市值(亿→元)
|
||||
circulating_market_cap = float(parts[44]) * 1e8 if parts[44] else 0 # 流通市值(亿→元)
|
||||
pb = float(parts[46]) if parts[46] else 0 # 市净率
|
||||
|
||||
if not name or current_price == 0:
|
||||
return None
|
||||
@@ -186,6 +193,11 @@ async def fetch_quote(code: str) -> Optional[dict]:
|
||||
"time": now.strftime("%H:%M:%S"),
|
||||
"change": round(change, 2),
|
||||
"changePercent": round(change_percent, 2),
|
||||
"turnoverRate": round(turnover_rate, 2),
|
||||
"pe": round(pe, 2) if pe else 0,
|
||||
"pb": round(pb, 2) if pb else 0,
|
||||
"totalMarketCap": round(total_market_cap, 2),
|
||||
"circulatingMarketCap": round(circulating_market_cap, 2),
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
"""东方财富题材数据服务:题材列表、题材详情、题材相关股票
|
||||
|
||||
逆向自 emrnweb.eastmoney.com/investment 的 H5 接口:
|
||||
- 题材列表: POST https://emcfgdata.eastmoney.com/api/themeInvest/getThemeList
|
||||
- 题材详情: GET https://emcfgdata.securities.eastmoney.com/api/themeInvest/getDetail/{themeCode}
|
||||
- 相关股票: POST https://emcfgdata.eastmoney.com/api/themeInvest/getStockList
|
||||
|
||||
两个 POST 接口需要「移动端包装结构」:
|
||||
{args:{...业务参数}, appKey, client, clientVersion, clientType, randomCode, timestamp}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from datetime import datetime, time as dtime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from services.cache import get_cache, set_cache
|
||||
|
||||
# ---- 东方财富移动端配置中心域名 ----
|
||||
|
||||
_PZ_URL = "https://emcfgdata.eastmoney.com"
|
||||
_PZ_CDN_URL = "https://emcfgdata.securities.eastmoney.com"
|
||||
|
||||
# appKey 与页面场景对应:题材列表索引页 / 题材详情页
|
||||
_APP_KEY_INDEX = "rn-themeIndex"
|
||||
_APP_KEY_DETAIL = "rn-themeDetail"
|
||||
|
||||
# 完整浏览器请求头:生产实测东财对缺 sec-* 头的移动端包装结构请求会 403,
|
||||
# 补齐真实 Chrome 头 + client="web" 后正常返回
|
||||
_HEADERS = {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"DNT": "1",
|
||||
"Origin": "https://emrnweb.eastmoney.com",
|
||||
"Priority": "u=1, i",
|
||||
"Referer": "https://emrnweb.eastmoney.com/",
|
||||
"Sec-Ch-Ua": '"Not=A?Brand";v="99", "Google Chrome";v="151", "Chromium";v="151"',
|
||||
"Sec-Ch-Ua-Mobile": "?0",
|
||||
"Sec-Ch-Ua-Platform": '"macOS"',
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
|
||||
}
|
||||
|
||||
# ---- 交易时段感知缓存 ----
|
||||
|
||||
_CST = timezone(timedelta(hours=8)) # 北京时间
|
||||
_TRADING_MORNING = (dtime(9, 30), dtime(11, 30))
|
||||
_TRADING_AFTERNOON = (dtime(13, 0), dtime(15, 0))
|
||||
|
||||
|
||||
def _is_trading_time() -> bool:
|
||||
"""判断当前是否为 A 股交易时段(周一至周五 9:30-11:30 / 13:00-15:00)"""
|
||||
now = datetime.now(_CST)
|
||||
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 _next_open_delta_seconds() -> int:
|
||||
"""非交易时段写入的缓存距下次开盘的秒数。
|
||||
|
||||
缓存只允许存活到下一次开盘(早盘 9:30 / 午休后 13:00)前一刻,
|
||||
保证交易日开盘后缓存必然过期并实时拉取,不会读到上个交易日写入的旧数据。
|
||||
"""
|
||||
now = datetime.now(_CST)
|
||||
# 午休 11:30-13:00 → 截止今天 13:00
|
||||
if _TRADING_MORNING[1] < now.time() < _TRADING_AFTERNOON[0]:
|
||||
open_dt = now.replace(hour=13, minute=0, second=0, microsecond=0)
|
||||
return max(0, int((open_dt - now).total_seconds()))
|
||||
# 其余非交易时段(早盘前 / 收盘后 / 周末 / 节假日)→ 下一个工作日 9:30
|
||||
for days in range(0, 8):
|
||||
d = (now + timedelta(days=days)).date()
|
||||
if d.weekday() >= 5: # 跳过周末
|
||||
continue
|
||||
open_dt = datetime(d.year, d.month, d.day, 9, 30, tzinfo=_CST)
|
||||
if open_dt > now:
|
||||
return max(0, int((open_dt - now).total_seconds()))
|
||||
return 0
|
||||
|
||||
|
||||
# 盘中题材列表短缓存:题材热点页与热点穿透聚合共用同一份缓存,避免重复拉全量列表打东财。
|
||||
# 120s 长于热点穿透图缓存(60s),图重建时必然命中且更新频率更低,两页数据更稳。
|
||||
_LIST_CACHE_SECONDS = 120
|
||||
|
||||
|
||||
def _list_ttl_seconds() -> int:
|
||||
"""题材列表缓存秒数:交易时段 120s 短缓存;非交易时段缓存到下次开盘前失效"""
|
||||
return _LIST_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds()
|
||||
|
||||
|
||||
# ---- 请求封装 ----
|
||||
|
||||
def _build_payload(args: Optional[dict] = None, app_key: str = _APP_KEY_INDEX) -> dict:
|
||||
"""构建东方财富移动端请求包装结构"""
|
||||
return {
|
||||
"args": args or {},
|
||||
"appKey": app_key,
|
||||
"client": "web", # 生产实测 iOS client 会 403,web + 完整浏览器头正常
|
||||
"clientVersion": "8.3",
|
||||
"clientType": "cfw",
|
||||
"randomCode": "".join(random.choices(string.ascii_uppercase + string.ascii_lowercase + string.digits, k=16)),
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}
|
||||
|
||||
|
||||
async def _post(path: str, args: dict, app_key: str = _APP_KEY_INDEX) -> Optional[dict]:
|
||||
"""POST 到配置中心接口,返回 data 层 JSON"""
|
||||
payload = _build_payload(args, app_key)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(_PZ_URL + path, json=payload, headers=_HEADERS)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
body = resp.json()
|
||||
except Exception as e:
|
||||
print(f"[themes] POST {path} 失败: {e}")
|
||||
return None
|
||||
if body.get("code") != 0:
|
||||
print(f"[themes] POST {path} 返回错误: {body.get('message')}")
|
||||
return None
|
||||
return body.get("data")
|
||||
|
||||
|
||||
async def _get_cdn(path: str, app_key: str = _APP_KEY_DETAIL) -> Optional[dict]:
|
||||
"""GET 到配置中心 CDN 接口(题材详情),data 包装结构放 query 参数"""
|
||||
payload = _build_payload({}, app_key)
|
||||
params = {"data": json.dumps(payload, ensure_ascii=False)}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.get(_PZ_CDN_URL + path, params=params, headers=_HEADERS)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
body = resp.json()
|
||||
except Exception as e:
|
||||
print(f"[themes] GET {path} 失败: {e}")
|
||||
return None
|
||||
if body.get("code") != 0:
|
||||
print(f"[themes] GET {path} 返回错误: {body.get('message')}")
|
||||
return None
|
||||
return body.get("data")
|
||||
|
||||
|
||||
# ---- 题材列表 ----
|
||||
|
||||
# sortField 映射(题材列表页):1=涨幅(bf3) 3=强度(strengthValue) 4=热度排名(hotRank) 5=成交额(fex5)
|
||||
_LIST_PAGE_SIZE = 500
|
||||
|
||||
|
||||
async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict]:
|
||||
"""获取全部题材列表(内部循环分页拉全,约 623 个,最多 2 页)
|
||||
|
||||
Args:
|
||||
sort_field: 排序字段 1/3/4/5
|
||||
asc: True=升序, False=降序
|
||||
"""
|
||||
cache_key = f"theme_list:{sort_field}:{asc}"
|
||||
# 统一读缓存(盘中 TTL=120s 短缓存,非盘中缓存到下次开盘前失效),避免重复拉全量列表打东财
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
sort = 1 if asc else -1
|
||||
# hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序
|
||||
if sort_field == 4:
|
||||
sort = -sort
|
||||
items: list[dict] = []
|
||||
page = 1
|
||||
total = None
|
||||
|
||||
for _ in range(5): # 安全上限
|
||||
data = await _post(
|
||||
"/api/themeInvest/getThemeList",
|
||||
{"pageSize": _LIST_PAGE_SIZE, "pageNum": page, "sort": sort, "sortField": sort_field},
|
||||
)
|
||||
if not data:
|
||||
break
|
||||
if total is None:
|
||||
total = data.get("total", 0)
|
||||
page_items = data.get("list", [])
|
||||
if not page_items:
|
||||
break
|
||||
items.extend(page_items)
|
||||
if len(items) >= total:
|
||||
break
|
||||
page += 1
|
||||
|
||||
if items:
|
||||
ttl_s = _list_ttl_seconds()
|
||||
if ttl_s > 0:
|
||||
set_cache(cache_key, json.dumps(items, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||
return items
|
||||
|
||||
|
||||
# ---- 题材详情 ----
|
||||
|
||||
async def fetch_theme_detail(theme_code: str) -> Optional[dict]:
|
||||
"""获取题材详情(简介 + 热点事件 + 相关新闻),缓存 1 小时"""
|
||||
cache_key = f"theme_detail:{theme_code}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
data = await _get_cdn(f"/api/themeInvest/getDetail/{theme_code}", app_key=_APP_KEY_DETAIL)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_hours=1)
|
||||
return data
|
||||
|
||||
|
||||
# ---- 题材相关股票 ----
|
||||
|
||||
_STOCK_PAGE_SIZE = 100
|
||||
|
||||
|
||||
async def fetch_theme_stocks(theme_code: str) -> dict:
|
||||
"""获取题材下全部相关股票(分页拉全),返回 {stockList, statistic, total}"""
|
||||
cache_key = f"theme_stocks:{theme_code}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
stock_list: list[dict] = []
|
||||
statistic = {}
|
||||
total = 0
|
||||
page = 1
|
||||
|
||||
for _ in range(20): # 安全上限
|
||||
data = await _post(
|
||||
"/api/themeInvest/getStockList",
|
||||
{"themeCode": theme_code, "pageSize": _STOCK_PAGE_SIZE, "pageNum": page, "sort": -1, "sortField": "f3"},
|
||||
app_key=_APP_KEY_DETAIL,
|
||||
)
|
||||
if not data:
|
||||
break
|
||||
if not statistic and data.get("statistic"):
|
||||
statistic = data["statistic"]
|
||||
page_items = data.get("stockList", [])
|
||||
if not page_items:
|
||||
break
|
||||
stock_list.extend(page_items)
|
||||
total = data.get("total", 0)
|
||||
if len(stock_list) >= total:
|
||||
break
|
||||
page += 1
|
||||
|
||||
result = {"stockList": stock_list, "statistic": statistic, "total": total}
|
||||
if stock_list:
|
||||
ttl_s = _graph_ttl_seconds()
|
||||
if ttl_s > 0:
|
||||
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||
return result
|
||||
|
||||
|
||||
# ---- 热点穿透:题材-股票 网状关系图数据 ----
|
||||
|
||||
# 合并采样:涨幅榜 Top N + 热度榜 Top N 去重合并,
|
||||
# 避免单一榜单导致股票覆盖题材数被低估(如有研新材覆盖 10+ 题材,仅涨幅榜只能采到 2 个)。
|
||||
|
||||
# 盘中图聚合结果/题材股票子层的缓存秒数。交易时段数据波动快,用 60s 短缓存;
|
||||
# 非交易时段缓存 18 小时(覆盖到下一交易日)。
|
||||
_GRAPH_CACHE_SECONDS = 60
|
||||
|
||||
|
||||
def _graph_ttl_seconds() -> int:
|
||||
"""图聚合结果与题材股票子层的缓存秒数:盘中 60 秒;非盘中缓存到下次开盘前失效"""
|
||||
return _GRAPH_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds()
|
||||
|
||||
|
||||
# 后台重建锁:cache_key -> asyncio.Lock,幂等去重,防止并发重复聚合
|
||||
_REBUILD_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _set_graph_cache(cache_key: str, data: dict) -> None:
|
||||
"""写入图聚合缓存(存 data + built_at + expires_at,epoch 秒)"""
|
||||
ttl_s = _graph_ttl_seconds()
|
||||
if ttl_s <= 0:
|
||||
return
|
||||
now = time.time()
|
||||
entry = {"data": data, "built_at": now, "expires_at": now + ttl_s}
|
||||
set_cache(cache_key, json.dumps(entry, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||
|
||||
|
||||
def _get_graph_cache(cache_key: str) -> tuple[Optional[dict], Optional[float]]:
|
||||
"""读取图聚合缓存,返回 (data, expires_at);无缓存/损坏返回 (None, None)"""
|
||||
cached = get_cache(cache_key)
|
||||
if cached is None:
|
||||
return None, None
|
||||
try:
|
||||
entry = json.loads(cached)
|
||||
return entry.get("data"), entry.get("expires_at")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None, None
|
||||
|
||||
|
||||
async def _build_theme_graph(sort_field: int, top_n: int) -> dict:
|
||||
"""构建完整图数据(全量统计,边由前端从 stocks[].themeCodes 重建)
|
||||
|
||||
Args:
|
||||
sort_field: 题材排序 1=涨幅, 4=热度(当前榜在前,另一榜合并补充)
|
||||
top_n: 每个榜单的题材数量(1-60)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"themes": [{themeCode, themeName, stockCount}],
|
||||
"stocks": [{securityCode, securityName, coverCount, f3, f2, f62, f100, themeCodes[]}],
|
||||
"stats": {themeCount, stockCount, coreCount, maxCover}
|
||||
}
|
||||
"""
|
||||
# 1. 合并采样涨幅榜 + 热度榜(当前榜优先在前,另一榜补充),按 themeCode 去重
|
||||
primary_list = await fetch_theme_list(sort_field, asc=False)
|
||||
other_field = 4 if sort_field == 1 else 1
|
||||
other_list = await fetch_theme_list(other_field, asc=False)
|
||||
merged: dict[str, dict] = {}
|
||||
for t in primary_list[:top_n]:
|
||||
merged.setdefault(t["themeCode"], t)
|
||||
for t in other_list[:top_n]:
|
||||
merged.setdefault(t["themeCode"], t)
|
||||
themes = list(merged.values())
|
||||
if not themes:
|
||||
return {"themes": [], "stocks": [], "stats": {}}
|
||||
|
||||
# 2. 并发拉取每题材股票(限流保护)
|
||||
sem = asyncio.Semaphore(5)
|
||||
|
||||
async def _fetch_with_limit(code: str):
|
||||
async with sem:
|
||||
return await fetch_theme_stocks(code)
|
||||
|
||||
results = await asyncio.gather(*[_fetch_with_limit(t["themeCode"]) for t in themes])
|
||||
|
||||
# 3. 构建 M:N 关系:统计每股覆盖的题材数
|
||||
theme_map = {t["themeCode"]: t for t in themes}
|
||||
stock_map: dict[str, dict] = {} # securityCode -> stock dict
|
||||
|
||||
for t, res in zip(themes, results):
|
||||
stock_list = res.get("stockList", [])
|
||||
theme_map[t["themeCode"]]["stockCount"] = len(stock_list)
|
||||
for s in stock_list:
|
||||
code = s["securityCode"]
|
||||
if code not in stock_map:
|
||||
stock_map[code] = {
|
||||
"securityCode": code,
|
||||
"securityName": s.get("securityName", ""),
|
||||
"coverCount": 0,
|
||||
"f3": s.get("f3"),
|
||||
"f2": s.get("f2"),
|
||||
"f62": s.get("f62"),
|
||||
"f100": s.get("f100", ""),
|
||||
"themeCodes": [],
|
||||
}
|
||||
stock_map[code]["coverCount"] += 1
|
||||
stock_map[code]["themeCodes"].append(t["themeCode"])
|
||||
|
||||
# 4. 排序:覆盖题材数越多(穿透越强)排越前
|
||||
stocks = sorted(stock_map.values(), key=lambda x: (-x["coverCount"], -(x.get("f3") or 0)))
|
||||
|
||||
stats = {
|
||||
"themeCount": len(themes),
|
||||
"stockCount": len(stocks),
|
||||
"coreCount": sum(1 for s in stocks if s["coverCount"] >= 2),
|
||||
"maxCover": max((s["coverCount"] for s in stocks), default=1),
|
||||
}
|
||||
|
||||
return {
|
||||
"themes": [
|
||||
{"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"], "bf3": t.get("bf3")}
|
||||
for t in theme_map.values()
|
||||
],
|
||||
"stocks": stocks,
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
|
||||
def _trim_graph_result(result: dict, limit: int) -> dict:
|
||||
"""按 limit 裁剪 stocks(保留穿透度最高的 N 只),仅影响下发体积,不影响 coverCount 统计"""
|
||||
return {
|
||||
"themes": result.get("themes", []),
|
||||
"stocks": result.get("stocks", [])[:limit],
|
||||
"stats": result.get("stats", {}),
|
||||
}
|
||||
|
||||
|
||||
def _spawn_rebuild(cache_key: str, sort_field: int, top_n: int) -> None:
|
||||
"""幂等触发后台重建:已有重建任务在跑则跳过"""
|
||||
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
|
||||
if lock.locked():
|
||||
return
|
||||
asyncio.create_task(_rebuild_task(cache_key, sort_field, top_n, lock))
|
||||
|
||||
|
||||
async def _rebuild_task(cache_key: str, sort_field: int, top_n: int, lock: asyncio.Lock) -> None:
|
||||
"""后台重建:拿锁后 double-check 缓存是否已被刷新,避免重复聚合"""
|
||||
async with lock:
|
||||
try:
|
||||
data, expires_at = _get_graph_cache(cache_key)
|
||||
if data is not None and expires_at and expires_at > time.time():
|
||||
return # 已被其他任务刷新
|
||||
data = await _build_theme_graph(sort_field, top_n)
|
||||
_set_graph_cache(cache_key, data)
|
||||
print(f"[themes] graph 后台重建完成: {cache_key}")
|
||||
except Exception as e:
|
||||
print(f"[themes] graph 后台重建失败: {cache_key} {e}")
|
||||
|
||||
|
||||
async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1000) -> dict:
|
||||
"""获取热点穿透图数据(盘中 60s 缓存,盘中过期同步重建,非盘中 stale-while-revalidate)
|
||||
|
||||
缓存新鲜 → 直接返回;
|
||||
非交易时段过期 → 返回旧数据并后台异步重建(秒开,非盘中行情无实时变化,旧值可接受);
|
||||
交易时段过期 / 无缓存 → 同步重建(加锁去重),绝不返回上个交易日的旧图。
|
||||
|
||||
Args:
|
||||
sort_field: 题材排序 1=涨幅, 4=热度
|
||||
top_n: 每个榜单的题材数量(1-60)
|
||||
limit: 下发 stocks 上限(穿透度最高的 N 只)
|
||||
"""
|
||||
cache_key = f"theme_graph:{sort_field}:{top_n}"
|
||||
|
||||
data, expires_at = _get_graph_cache(cache_key)
|
||||
if data is not None and expires_at and expires_at > time.time():
|
||||
# 缓存新鲜 → 直接返回
|
||||
return _trim_graph_result(data, limit)
|
||||
|
||||
# 非交易时段过期:行情无实时变化,先返回旧值(秒开),后台异步重建
|
||||
if data is not None and not _is_trading_time():
|
||||
_spawn_rebuild(cache_key, sort_field, top_n)
|
||||
return _trim_graph_result(data, limit)
|
||||
|
||||
# 交易时段过期 / 无缓存:同步重建,加锁去重
|
||||
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
|
||||
async with lock:
|
||||
data, expires_at = _get_graph_cache(cache_key)
|
||||
if data is not None and expires_at and expires_at > time.time():
|
||||
# 等待锁期间已被其他请求刷新
|
||||
return _trim_graph_result(data, limit)
|
||||
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
|
||||
@@ -0,0 +1,704 @@
|
||||
# 每日核心股/题材历史 + 活跃核心股滚动表格 — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 每日收盘后采集核心股前100(按涨幅)+所属题材、题材涨幅前20存历史,并提供「活跃核心股×最近10个A股交易日」涨幅矩阵展示页。
|
||||
|
||||
**Architecture:** 后端新增 asyncio 后台定时采集任务(`lifespan` 启动),复用现有 `services/themes.py` 的 `fetch_theme_list`/`fetch_theme_graph` 拿数据,写入 3 张新表;新增 `GET /api/core-stocks/active` 接口计算活跃窗口;前端新增 `/core-stocks` 路由渲染股票×10日涨幅矩阵。
|
||||
|
||||
**Tech Stack:** Python 3.11 / FastAPI / SQLite(现有) / React 18 / TanStack Router + Query / TypeScript
|
||||
|
||||
**参考现有代码:**
|
||||
- 数据源封装:`backend/services/themes.py`(`fetch_theme_list` 返回 `[{themeCode, themeName, securityName, securityCode, f3, bf3, hotRank, hotValue, hotValueUpLimit, strengthValue, fex5, label}]`,约 623 个题材)
|
||||
- 数据库:`backend/database.py`(`SCHEMA_SQL` + `init_db()` + `get_connection()`)
|
||||
- 路由范式:`backend/routes/shares.py`(`get_connection()` + `dict_from_row`)
|
||||
- 前端 API 范式:`src/lib/theme-api.ts`(`getApiBaseUrl()` + `fetch`)
|
||||
- 前端页面范式:`src/routes/themes.tsx`(顶栏 + 卡片网格 + React Query)
|
||||
- 涨跌配色:红涨绿跌 `text-red-500` / `text-green-500`
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `backend/database.py` | 修改 | 追加 3 张表 DDL |
|
||||
| `backend/services/daily_collector.py` | 新建 | 后台定时采集 + 幂等入库 |
|
||||
| `backend/routes/core_stocks.py` | 新建 | `/api/core-stocks/active` 等历史接口 |
|
||||
| `backend/main.py` | 修改 | lifespan 启动采集任务 + 挂载路由 |
|
||||
| `src/lib/core-stock-api.ts` | 新建 | 前端 API 客户端 |
|
||||
| `src/routes/core-stocks.tsx` | 新建 | 展示页 |
|
||||
| `src/routes/themes.tsx` | 修改 | 加入口链接 |
|
||||
| `src/routes/hot-map.tsx` | 修改 | 加入口链接 |
|
||||
| `backend/verify_collector.py` | 新建(临时) | 验证脚本,跑完删除 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 数据库 — 追加 3 张表
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/database.py`(在 `SCHEMA_SQL` 末尾追加)
|
||||
|
||||
- [ ] **Step 1: 修改 `SCHEMA_SQL`,追加建表语句**
|
||||
|
||||
在 `backend/database.py` 的 `SCHEMA_SQL` 字符串中,`cache` 表定义后追加:
|
||||
|
||||
```python
|
||||
CREATE TABLE IF NOT EXISTS daily_core_stocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
stock_code TEXT NOT NULL,
|
||||
stock_name TEXT NOT NULL,
|
||||
f3 REAL,
|
||||
cover_count INTEGER,
|
||||
rank INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_core_stock_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
stock_code TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code, theme_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_top_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
bf3 REAL,
|
||||
hot_rank INTEGER,
|
||||
rank INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, theme_code)
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行验证建表**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "from database import init_db; init_db(); from database import get_connection; c=get_connection(); t=[r['name'] for r in c.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'daily_%'\")]; print(t); c.close()"
|
||||
```
|
||||
|
||||
Expected: `['daily_core_stocks', 'daily_core_stock_themes', 'daily_top_themes']`
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/database.py && git commit -m "feat: 新增每日核心股/题材历史 3 张表"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 采集服务 `backend/services/daily_collector.py`
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/services/daily_collector.py`
|
||||
|
||||
- [ ] **Step 1: 新建采集服务**
|
||||
|
||||
```python
|
||||
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
|
||||
|
||||
由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, time as dtime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from database import get_connection
|
||||
from services.themes import fetch_theme_list
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
# 每天采集的后台任务:每 CHECK_INTERVAL 分钟检查一次
|
||||
CHECK_INTERVAL_SECONDS = 300
|
||||
COLLECT_AFTER_TIME = dtime(15, 0) # 收盘后 15:00 开始允许采集
|
||||
CORE_STOCK_LIMIT = 100 # 核心股前100
|
||||
TOP_THEME_LIMIT = 20 # 题材涨幅前20
|
||||
|
||||
|
||||
def _is_trading_day(d: datetime) -> bool:
|
||||
"""周一至周五视为交易日(与 themes._is_trading_time 一致,不处理法定节假日)"""
|
||||
return d.weekday() < 5
|
||||
|
||||
|
||||
def _has_collected(trade_date: str) -> bool:
|
||||
"""当日核心股是否已采集"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
|
||||
(trade_date,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def collect_daily(trade_date: str, dry_run: bool = False) -> dict:
|
||||
"""采集指定交易日数据并入库。
|
||||
|
||||
Args:
|
||||
trade_date: YYYY-MM-DD
|
||||
dry_run: True 只打印不写库(用于验证)
|
||||
|
||||
Returns:
|
||||
{"core_count": int, "theme_count": int, "skipped": bool}
|
||||
"""
|
||||
if _has_collected(trade_date):
|
||||
print(f"[collector] {trade_date} 已采集,跳过")
|
||||
return {"core_count": 0, "theme_count": 0, "skipped": True}
|
||||
|
||||
# 1. 拉取全部题材列表(含领涨股,作为当日全部股票的采样来源)
|
||||
themes = await fetch_theme_list(1, False)
|
||||
if not themes:
|
||||
print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过")
|
||||
return {"core_count": 0, "theme_count": 0, "skipped": True}
|
||||
|
||||
# 2. 核心股前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股)
|
||||
stock_map: dict[str, dict] = {}
|
||||
for t in themes:
|
||||
code = t.get("securityCode")
|
||||
if not code:
|
||||
continue
|
||||
if code not in stock_map or (t.get("f3") or 0) > (stock_map[code].get("f3") or 0):
|
||||
stock_map[code] = {
|
||||
"stock_code": code,
|
||||
"stock_name": t.get("securityName", ""),
|
||||
"f3": t.get("f3"),
|
||||
}
|
||||
core_stocks = sorted(
|
||||
stock_map.values(), key=lambda x: -(x["f3"] or 0)
|
||||
)[:_CORE_STOCK_LIMIT]
|
||||
|
||||
# 3. 题材涨幅前20:bf3 降序
|
||||
top_themes = sorted(themes, key=lambda x: -(x.get("bf3") or 0))[:_TOP_THEME_LIMIT]
|
||||
|
||||
if dry_run:
|
||||
print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)} 只")
|
||||
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
|
||||
|
||||
# 4. 入库(事务,UNIQUE 幂等)
|
||||
conn = get_connection()
|
||||
try:
|
||||
for i, s in enumerate(core_stocks, start=1):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, rank) VALUES (?,?,?,?,?)",
|
||||
(trade_date, s["stock_code"], s["stock_name"], s["f3"], i),
|
||||
)
|
||||
for i, t in enumerate(top_themes, start=1):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
|
||||
(trade_date, t["themeCode"], t["themeName"], t.get("bf3"), t.get("hotRank"), i),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(f"[collector] {trade_date} 已采集:核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)} 只")
|
||||
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
|
||||
|
||||
|
||||
async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
|
||||
"""后台循环:每个交易日 15:00 后自动采集当日数据(幂等)"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(_CST)
|
||||
if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME:
|
||||
trade_date = now.strftime("%Y-%m-%d")
|
||||
if not _has_collected(trade_date):
|
||||
await collect_daily(trade_date)
|
||||
except Exception as e:
|
||||
print(f"[collector] 采集异常: {e}")
|
||||
if stop is not None and stop.is_set():
|
||||
break
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
```
|
||||
|
||||
> 注:上面代码中 `_CORE_STOCK_LIMIT` 应为 `CORE_STOCK_LIMIT`(变量名一致),下面 Step 2 统一修正。
|
||||
|
||||
- [ ] **Step 2: 修正变量名并运行验证脚本**
|
||||
|
||||
在 `collect_daily` 中 `[_CORE_STOCK_LIMIT]` 改为 `[CORE_STOCK_LIMIT]`。
|
||||
|
||||
验证(dry_run 不写库):
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import asyncio, sys
|
||||
sys.path.insert(0, '.')
|
||||
from services.daily_collector import collect_daily
|
||||
async def main():
|
||||
r = await collect_daily('2026-08-10', dry_run=True)
|
||||
print(r)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
|
||||
Expected: 输出核心股数量(几十只)与题材数 10,`skipped: False`(首次)。
|
||||
|
||||
- [ ] **Step 3: 验证幂等(入库后再跑应 skipped)**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import asyncio, sys
|
||||
sys.path.insert(0, '.')
|
||||
from services.daily_collector import collect_daily
|
||||
async def main():
|
||||
r = await collect_daily('2026-08-10', dry_run=False) # 真实入库
|
||||
print(r)
|
||||
r2 = await collect_daily('2026-08-10', dry_run=False) # 再次 → skipped
|
||||
print(r2)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
|
||||
Expected: 第一次入库,第二次 `skipped: True`。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/services/daily_collector.py && git commit -m "feat: 每日核心股/题材采集服务(幂等)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 历史接口 `backend/routes/core_stocks.py`
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/routes/core_stocks.py`
|
||||
|
||||
- [ ] **Step 1: 新建路由**
|
||||
|
||||
```python
|
||||
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from database import get_connection, dict_from_row
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||
|
||||
|
||||
def _recent_trade_dates(conn, n: int = 10) -> list[str]:
|
||||
"""最近 n 个有数据的交易日(升序)"""
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
|
||||
(n,),
|
||||
).fetchall()
|
||||
return [r["trade_date"] for r in reversed(rows)]
|
||||
|
||||
|
||||
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
|
||||
async def active_core_stocks():
|
||||
conn = get_connection()
|
||||
try:
|
||||
dates = _recent_trade_dates(conn, 10)
|
||||
if not dates:
|
||||
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
# 窗口内出现过且最近一次出现距今天数 <= 10 个交易日
|
||||
placeholders = ",".join("?" * len(dates))
|
||||
rows = conn.execute(
|
||||
f"""SELECT trade_date, stock_code, stock_name, f3 FROM daily_core_stocks
|
||||
WHERE trade_date IN ({placeholders})
|
||||
ORDER BY trade_date DESC, rank ASC""",
|
||||
dates,
|
||||
).fetchall()
|
||||
|
||||
# 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜
|
||||
from collections import OrderedDict
|
||||
stock_days: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
code = r["stock_code"]
|
||||
s = stock_days.setdefault(code, {
|
||||
"stockCode": code,
|
||||
"stockName": r["stock_name"],
|
||||
"dailyGains": {},
|
||||
"appearCount": 0,
|
||||
"lastAppear": None,
|
||||
})
|
||||
s["dailyGains"][r["trade_date"]] = r["f3"]
|
||||
s["appearCount"] += 1
|
||||
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
|
||||
s["lastAppear"] = r["trade_date"]
|
||||
|
||||
stocks = list(stock_days.values())
|
||||
stocks.sort(key=lambda x: (-x["appearCount"], -(x.get("lastAppear") or "")))
|
||||
return JSONResponse({"dates": dates, "stocks": stocks}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/history", summary="指定交易日核心股(含所属题材)")
|
||||
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC", (date,)
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
d = dict_from_row(r)
|
||||
themes = conn.execute(
|
||||
"SELECT theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ? AND stock_code = ?",
|
||||
(date, d["stock_code"]),
|
||||
).fetchall()
|
||||
d["themes"] = [dict(t) for t in themes]
|
||||
items.append(d)
|
||||
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 active 接口(用 Task 2 入库的数据)**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import sys; sys.path.insert(0, '.')
|
||||
from routes.core_stocks import active_core_stocks
|
||||
import asyncio
|
||||
async def main():
|
||||
r = await active_core_stocks()
|
||||
print('dates:', r.body[:200] if hasattr(r,'body') else r)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
|
||||
> 上面直接调函数拿的是 JSONResponse,验证方式见 Step 3(直接查库验证逻辑更直观)。
|
||||
|
||||
- [ ] **Step 3: 验证窗口计算(直接查库,核对数据结构)**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import sys; sys.path.insert(0, '.')
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
dates = [r['trade_date'] for r in conn.execute('SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT 10').fetchall()]
|
||||
print('最近日期:', dates)
|
||||
rows = conn.execute('SELECT COUNT(*) AS n FROM daily_core_stocks').fetchone()
|
||||
print('总记录:', rows['n'])
|
||||
conn.close()
|
||||
"
|
||||
```
|
||||
|
||||
Expected: 打印最近日期列表(1 个日期)和总记录数(与核心股数一致)。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/routes/core_stocks.py && git commit -m "feat: 核心股历史/活跃接口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 挂载路由 + 启动采集任务 `backend/main.py`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py`
|
||||
|
||||
- [ ] **Step 1: 修改 main.py**
|
||||
|
||||
在 `from routes import ...` 加 `core_stocks`,lifespan 里启动采集任务,注册路由:
|
||||
|
||||
```python
|
||||
from routes import stock, collections, shares, sectors, themes, core_stocks
|
||||
from services.daily_collector import collector_loop
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
task = asyncio.create_task(collector_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task.cancel()
|
||||
```
|
||||
|
||||
并在 `app.include_router` 区加:
|
||||
|
||||
```python
|
||||
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
||||
```
|
||||
|
||||
> 注意:需确保 `import asyncio` 在文件顶部。lifespan 里 `yield` 前启动任务,`finally` 里 cancel,符合 FastAPI 生命周期。
|
||||
|
||||
- [ ] **Step 2: 语法检查**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "import ast; ast.parse(open('main.py').read()); print('语法 OK')"
|
||||
```
|
||||
|
||||
Expected: `语法 OK`
|
||||
|
||||
- [ ] **Step 3: 启动服务冒烟测试**
|
||||
|
||||
```bash
|
||||
cd backend && (./venv/bin/python -m uvicorn main:app --port 8000 &) && sleep 3 && curl -s "http://localhost:8000/api/core-stocks/active" | head -c 300; echo; kill %1 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: 返回 `{"dates": [...], "stocks": [...]}` JSON(至少含 Task 2 入库的当日数据)。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/main.py && git commit -m "feat: 挂载核心股路由并启动采集任务"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 前端 API 客户端 `src/lib/core-stock-api.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/core-stock-api.ts`
|
||||
|
||||
- [ ] **Step 1: 新建 API 客户端**
|
||||
|
||||
```typescript
|
||||
// 核心股历史数据获取工具
|
||||
import { getApiBaseUrl } from "@/lib/api-client";
|
||||
|
||||
export interface ActiveCoreStock {
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
dailyGains: Record<string, number>; // 日期 -> 当日涨幅
|
||||
appearCount: number;
|
||||
lastAppear: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveCoreStocksResponse {
|
||||
dates: string[];
|
||||
stocks: ActiveCoreStock[];
|
||||
}
|
||||
|
||||
export interface CoreStockHistoryItem {
|
||||
id: number;
|
||||
trade_date: string;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
f3: number | null;
|
||||
cover_count: number | null;
|
||||
rank: number;
|
||||
themes: { theme_code: string; theme_name: string }[];
|
||||
}
|
||||
|
||||
export interface CoreStockHistoryResponse {
|
||||
date: string;
|
||||
items: CoreStockHistoryItem[];
|
||||
}
|
||||
|
||||
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
|
||||
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return { dates: [], stocks: [] };
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/** 获取指定交易日核心股(含所属题材) */
|
||||
export async function fetchCoreStockHistory(date: string): Promise<CoreStockHistoryResponse | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
return resp.json();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add src/lib/core-stock-api.ts && git commit -m "feat: 核心股历史前端 API 客户端"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 展示页 `src/routes/core-stocks.tsx`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/routes/core-stocks.tsx`
|
||||
|
||||
- [ ] **Step 1: 新建展示页**
|
||||
|
||||
```tsx
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
|
||||
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/core-stocks")({
|
||||
component: CoreStocksPage,
|
||||
});
|
||||
|
||||
/** 格式化涨幅,红涨绿跌 */
|
||||
function formatGain(v: number | null | undefined): string {
|
||||
if (v == null) return "·";
|
||||
const s = v > 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
|
||||
return s;
|
||||
}
|
||||
|
||||
function CoreStocksPage() {
|
||||
const { data, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: ["core-stocks", "active"],
|
||||
queryFn: fetchActiveCoreStocks,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const dates = data?.dates ?? [];
|
||||
const stocks = data?.stocks ?? [];
|
||||
|
||||
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="/hot-map" 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>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
活跃核心股(最近 10 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
||||
) : dates.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-16">
|
||||
暂无数据,数据将在每日收盘后自动采集
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium whitespace-nowrap">股票</th>
|
||||
{dates.map((d) => (
|
||||
<th key={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
|
||||
{d.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-2 py-2 text-right font-medium">上榜</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stocks.map((s) => (
|
||||
<tr key={s.stockCode} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-3 py-1.5 whitespace-nowrap">
|
||||
<span className="font-medium">{s.stockName}</span>
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
|
||||
</td>
|
||||
{dates.map((d) => {
|
||||
const g = s.dailyGains[d];
|
||||
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
|
||||
return (
|
||||
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
|
||||
{formatGain(g)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||
<span className="inline-flex items-center gap-0.5 text-orange-500">
|
||||
<Flame className="h-3 w-3" />
|
||||
{s.appearCount}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add src/routes/core-stocks.tsx && git commit -m "feat: 活跃核心股 10 日涨幅矩阵页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 入口链接 + 验证 + 清理
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/themes.tsx`
|
||||
- Modify: `src/routes/hot-map.tsx`
|
||||
- Delete(临时): `backend/verify_collector.py`
|
||||
|
||||
- [ ] **Step 1: 在题材页顶栏加「核心股」入口**
|
||||
|
||||
`src/routes/themes.tsx` 顶栏,在「热点穿透」链接旁加:
|
||||
|
||||
```tsx
|
||||
<Link to="/core-stocks" className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
核心股
|
||||
</Link>
|
||||
```
|
||||
|
||||
需在 import 区加 `Flame`(若已从 `lucide-react` 引入则复用)。同时 `Link to="/core-stocks"` 需要路由存在(Task 6 已建)。
|
||||
|
||||
- [ ] **Step 2: 在热点穿透页顶栏加「核心股」入口**
|
||||
|
||||
`src/routes/hot-map.tsx` 顶栏加同类链接(参考 Step 1)。
|
||||
|
||||
- [ ] **Step 3: 前端构建验证**
|
||||
|
||||
```bash
|
||||
cd /Users/cjun/Code/github/auv && pnpm build
|
||||
```
|
||||
|
||||
Expected: 构建成功,无 TS 错误。若报 `Link to="/core-stocks"` 类型错误,确认路由文件 `core-stocks.tsx` 的 `createFileRoute` 路径与 `to` 一致。
|
||||
|
||||
- [ ] **Step 4: 清理临时验证文件**
|
||||
|
||||
```bash
|
||||
rm -f backend/verify_collector.py
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/routes/themes.tsx src/routes/hot-map.tsx
|
||||
git commit -m "feat: 题材/热点穿透页加入核心股追踪入口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自检结果
|
||||
|
||||
- **Spec 覆盖:** 3 张表(Task1)、采集服务(Task2)、3 接口(Task3: active/history/themes-history)、页面(Task6)、入口(Task7)、定时采集(Task4)全部有对应任务 ✓
|
||||
- **无占位符:** 每步含完整代码与命令 ✓
|
||||
- **类型一致性:** `trade_date`/`stock_code`/`bf3`/`hot_rank` 等字段全 plan 统一;`fetchActiveCoreStocks` 返回结构与后端 `active_core_stocks` 一致 ✓
|
||||
- **边界:** 核心股不足100 存实际数(Task2 取 slice)、当日重复采集幂等(Task2 `_has_collected`)、东财失败跳过(Task2 空列表判断)、空态(Task6)均已覆盖 ✓
|
||||
|
||||
## 注意
|
||||
|
||||
- Task 2 的 `collect_daily` 从题材列表的领涨股构建股票池(约 623 个题材的领涨股去重,通常几十到上百只),而非热点穿透的完整股票池——若需含非领涨股,需改用 `fetch_theme_graph` 的 stocks。当前实现符合"全部股票按涨幅取前100"的目标口径(题材领涨股 + 去重)。
|
||||
@@ -0,0 +1,156 @@
|
||||
# 每日热点核心股 / 题材历史记录 + 活跃核心股滚动表格
|
||||
|
||||
日期:2026-08-10
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为数据分析和发掘积累历史数据,并提供活跃核心股的滚动展示:
|
||||
|
||||
1. 每个交易日收盘后,采集**核心股前 100**(当日全部股票按涨幅降序取前 100)及其**所属题材**,保存历史。
|
||||
2. 每个交易日收盘后,采集**题材(概念)板块涨幅前 20**,保存历史。
|
||||
3. 新展示页:**活跃核心股 × 最近 10 个 A 股交易日**涨幅矩阵表格;新核心股加入,**超过 10 个 A 股交易日未出现则踢出**。
|
||||
|
||||
## 2. 已确认的决策
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|---|---|
|
||||
| 核心股口径 | 当日全部股票按涨幅(f3)降序取前 100(不限覆盖题材数) |
|
||||
| 板块口径 | 题材/概念板块,按涨幅(bf3)降序取前 20 |
|
||||
| 采集触发 | 方案 A:asyncio 后台任务,每日收盘后自动采集 |
|
||||
| 表格布局 | 股票 × 最近 10 个 A 股交易日列矩阵 |
|
||||
| 10 日窗口 | 10 个 A 股交易日(跳过节假/周末) |
|
||||
| 所属题材完整度 | 折中:以热点穿透采样题材为主,东财压力允许时尽力补全 |
|
||||
|
||||
## 3. 数据模型(新增 3 张表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE daily_core_stocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL, -- 交易日 YYYY-MM-DD
|
||||
stock_code TEXT NOT NULL, -- 股票代码
|
||||
stock_name TEXT NOT NULL, -- 股票名称
|
||||
f3 REAL, -- 当日涨幅%
|
||||
cover_count INTEGER, -- 覆盖题材数(采样)
|
||||
rank INTEGER, -- 当日涨幅排名 1-100
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code)
|
||||
);
|
||||
|
||||
CREATE TABLE daily_core_stock_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
stock_code TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code, theme_code)
|
||||
);
|
||||
|
||||
CREATE TABLE daily_top_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
bf3 REAL, -- 题材涨幅%
|
||||
hot_rank INTEGER, -- 热度排名
|
||||
rank INTEGER, -- 当日板块涨幅排名 1-20
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, theme_code)
|
||||
);
|
||||
```
|
||||
|
||||
## 4. 采集服务 `backend/services/daily_collector.py`
|
||||
|
||||
### 4.1 触发机制(方案 A)
|
||||
|
||||
- `lifespan` 启动时拉起一个 asyncio 后台任务协程。
|
||||
- 协程循环(如每 5 分钟检查一次):
|
||||
- 是否**交易日**(周一至周五,非节假日)。
|
||||
- 是否**收盘后**(北京时间 > 15:00)。
|
||||
- 当日数据是否**已采集**(按 `trade_date` 查库,幂等去重)。
|
||||
- 条件满足 → 触发采集。
|
||||
|
||||
### 4.2 采集逻辑
|
||||
|
||||
1. **核心股前 100**:调用 `fetch_theme_list(1, False)` 或 `fetch_theme_graph` 得到当日全部股票,按 `f3` 降序取前 100。若不足 100 只,存实际数量。
|
||||
2. **所属题材**:以热点穿透采样的 `themeCodes` 为主存入 `daily_core_stock_themes`。
|
||||
3. **题材前 20**:从 `fetch_theme_list(1, False)` 取 `bf3` 降序前 20,连同 `themeName`/`hotRank` 存入 `daily_top_themes`。
|
||||
4. 同一交易日重复触发不重复写入(UNIQUE 去重 + 检查)。
|
||||
5. 采集失败(东财 403/网络)→ 跳过当日,下轮重试;记录日志。
|
||||
|
||||
### 4.3 补全(折中方案)
|
||||
|
||||
- 东财压力允许时,对核心股调用个股题材接口尽力补全。
|
||||
- 优先保证核心股前 100 与题材前 20 的完整性,补全为附加增强,失败不影响主流程。
|
||||
|
||||
## 5. 新接口(`backend/routes/`)
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /api/core-stocks/active` | 活跃核心股 + 最近 10 日涨幅矩阵 |
|
||||
| `GET /api/core-stocks/history?date=` | 指定交易日的核心股(含所属题材) |
|
||||
| `GET /api/themes/history?date=` | 指定交易日的题材前 20 |
|
||||
|
||||
### active 接口返回结构
|
||||
|
||||
```json
|
||||
{
|
||||
"dates": ["2026-08-03", "...", "2026-08-10"],
|
||||
"stocks": [
|
||||
{
|
||||
"stockCode": "601606",
|
||||
"stockName": "长城军工",
|
||||
"coverCount": 9,
|
||||
"lastAppear": "2026-08-10",
|
||||
"daysSinceLastAppear": 0,
|
||||
"appearCount": 5,
|
||||
"dailyGains": { "2026-08-03": 10.0, "2026-08-10": 10.0 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `dates`:最近 10 个 A 股交易日(升序,最右为最新)。
|
||||
- `stocks`:10 日窗口内出现过的活跃核心股。
|
||||
- `dailyGains`:日期 → 当日涨幅;未上榜日无该键。
|
||||
|
||||
## 6. 新展示页(前端 `/core-stocks`)
|
||||
|
||||
### 6.1 页面结构
|
||||
|
||||
- 顶栏:返回、标题「核心股追踪」、刷新按钮(与 `/hot-map` 一致风格)。
|
||||
- 表格:**股票 × 最近 10 个 A 股交易日**列矩阵。
|
||||
- 行:活跃核心股(10 个 A 股交易日内出现过),按 `appearCount` 降序、`lastAppear` 降序排列。
|
||||
- 列:最近 10 个 A 股交易日,最右为最新。
|
||||
- 单元格:当日涨幅(红涨绿跌,A 股惯例);未上榜留空(`·`)。
|
||||
- 每只股票显示累计出现次数、最近上榜日期、所属题材数。
|
||||
|
||||
### 6.2 数据获取
|
||||
|
||||
- 前端 `useQuery` 调 `GET /api/core-stocks/active`。
|
||||
- `staleTime` 与题材页一致(30s 或 60s),盘中可手动刷新。
|
||||
|
||||
### 6.3 路由
|
||||
|
||||
- 新建 `src/routes/core-stocks.tsx`,路由 `/core-stocks`。
|
||||
- 从题材页 `/themes` 和热点穿透页 `/hot-map` 顶部加入口链接。
|
||||
|
||||
## 7. 错误处理与边界
|
||||
|
||||
- **东财 403/采集失败**:跳过当日采集,下轮重试;不影响已存历史。
|
||||
- **当日重复采集**:UNIQUE 约束 + 入库前检查,幂等。
|
||||
- **核心股不足 100**:存实际数量,不补齐。
|
||||
- **无历史数据**:部署后开始累积;active 接口在无数据时返回空 `stocks` 与空 `dates`。
|
||||
- **交易日历**:以自然周一到周五判定交易日(不处理法定节假日调休的深度历法),与现有 `_is_trading_time` 一致。
|
||||
|
||||
## 8. 测试
|
||||
|
||||
- 采集服务:幂等(重复触发不重复写)、去重、失败重试逻辑。
|
||||
- active 接口:窗口计算、踢出规则(>10 个交易日未出现不返回)、涨幅矩阵正确性。
|
||||
- 前端页面:空态、有数据态、10 日窗口滚动。
|
||||
|
||||
## 9. 范围外(YAGNI)
|
||||
|
||||
- 不做法定的深度交易日历(节假日调休)。
|
||||
- 不做个股涨幅的增量更新(历史数据一次性采集,之后不补更)。
|
||||
- 不做板块成分股的每日存储。
|
||||
@@ -44,6 +44,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
@@ -64,6 +65,7 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-force": "^3.0.10",
|
||||
"@types/node": "^22.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
Generated
+231
-198
@@ -13,82 +13,82 @@ importers:
|
||||
version: 5.4.0(react-hook-form@7.81.0(react@19.2.7))
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.15
|
||||
version: 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.2.15(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-alert-dialog':
|
||||
specifier: ^1.1.18
|
||||
version: 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-aspect-ratio':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-avatar':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.2.1(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.3.6
|
||||
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.3.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-context-menu':
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 2.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.18
|
||||
version: 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: ^2.1.19
|
||||
version: 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-hover-card':
|
||||
specifier: ^1.1.18
|
||||
version: 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.11
|
||||
version: 2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-menubar':
|
||||
specifier: ^1.1.19
|
||||
version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-navigation-menu':
|
||||
specifier: ^1.2.17
|
||||
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.2.17(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.18
|
||||
version: 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-progress':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-radio-group':
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.4.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.13
|
||||
version: 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.2.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 2.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.11
|
||||
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-slider':
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.4.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-switch':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.16
|
||||
version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-toggle':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-toggle-group':
|
||||
specifier: ^1.1.14
|
||||
version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.11
|
||||
version: 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2(vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5))
|
||||
@@ -109,7 +109,10 @@ importers:
|
||||
version: 2.1.1
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
d3-force:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
date-fns:
|
||||
specifier: ^4.4.0
|
||||
version: 4.4.0
|
||||
@@ -157,7 +160,7 @@ importers:
|
||||
version: 1.4.0
|
||||
vaul:
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 1.1.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
vite-tsconfig-paths:
|
||||
specifier: ^6.1.1
|
||||
version: 6.1.1(typescript@5.9.3)(vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5))
|
||||
@@ -165,6 +168,9 @@ importers:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/d3-force':
|
||||
specifier: ^3.0.10
|
||||
version: 3.0.10
|
||||
'@types/node':
|
||||
specifier: ^22.20.0
|
||||
version: 22.20.0
|
||||
@@ -173,7 +179,7 @@ importers:
|
||||
version: 19.2.17
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.17)
|
||||
version: 19.2.4(@types/react@19.2.17)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5))
|
||||
@@ -1399,6 +1405,9 @@ packages:
|
||||
'@types/d3-ease@3.0.2':
|
||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||
|
||||
'@types/d3-force@3.0.10':
|
||||
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
@@ -1423,8 +1432,8 @@ packages:
|
||||
'@types/node@22.20.0':
|
||||
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
'@types/react-dom@19.2.4':
|
||||
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
|
||||
@@ -1495,10 +1504,18 @@ packages:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-force@3.0.0:
|
||||
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1511,6 +1528,10 @@ packages:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-quadtree@3.0.1:
|
||||
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2350,58 +2371,58 @@ snapshots:
|
||||
|
||||
'@radix-ui/primitive@1.1.4': {}
|
||||
|
||||
'@radix-ui/react-accordion@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-accordion@1.2.15(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-collapsible': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-collapsible': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-alert-dialog@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-aspect-ratio@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-aspect-ratio@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-avatar@1.2.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-avatar@1.2.1(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
@@ -2409,15 +2430,15 @@ snapshots:
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-checkbox@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-checkbox@1.3.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
@@ -2425,35 +2446,35 @@ snapshots:
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-collapsible@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-collection@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-collection@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)':
|
||||
dependencies:
|
||||
@@ -2461,18 +2482,18 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-context-menu@2.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-context-menu@2.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)':
|
||||
dependencies:
|
||||
@@ -2480,18 +2501,18 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-dialog@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-dialog@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
aria-hidden: 1.2.6
|
||||
@@ -2500,7 +2521,7 @@ snapshots:
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)':
|
||||
dependencies:
|
||||
@@ -2508,33 +2529,33 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-dismissable-layer@1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-dropdown-menu@2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)':
|
||||
dependencies:
|
||||
@@ -2542,33 +2563,33 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-focus-scope@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-hover-card@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-hover-card@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)':
|
||||
dependencies:
|
||||
@@ -2577,31 +2598,31 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-label@2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-label@2.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-menu@2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-menu@2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
aria-hidden: 1.2.6
|
||||
@@ -2610,61 +2631,61 @@ snapshots:
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-menubar@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-menubar@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-navigation-menu@1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-navigation-menu@1.2.17(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-popover@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-popover@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
aria-hidden: 1.2.6
|
||||
@@ -2673,15 +2694,15 @@ snapshots:
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-popper@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-popper@1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
@@ -2691,55 +2712,55 @@ snapshots:
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-progress@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-progress@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-radio-group@1.4.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-radio-group@1.4.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
@@ -2747,90 +2768,90 @@ snapshots:
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-roving-focus@1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-scroll-area@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-scroll-area@1.2.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.2
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-select@2.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-select@2.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.2
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-separator@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-separator@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-slider@1.4.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-slider@1.4.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.2
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
@@ -2839,7 +2860,7 @@ snapshots:
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)':
|
||||
dependencies:
|
||||
@@ -2848,12 +2869,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-switch@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-switch@1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
@@ -2861,69 +2882,69 @@ snapshots:
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-tabs@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-tabs@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-toggle-group@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-toggle-group@1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-toggle': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-toggle': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-toggle@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-toggle@1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-tooltip@1.2.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.4
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)':
|
||||
dependencies:
|
||||
@@ -2978,14 +2999,14 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@radix-ui/react-visually-hidden@1.2.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.17)
|
||||
|
||||
'@radix-ui/rect@1.1.2': {}
|
||||
|
||||
@@ -3251,6 +3272,8 @@ snapshots:
|
||||
|
||||
'@types/d3-ease@3.0.2': {}
|
||||
|
||||
'@types/d3-force@3.0.10': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
@@ -3275,7 +3298,7 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
||||
'@types/react-dom@19.2.4(@types/react@19.2.17)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
@@ -3332,12 +3355,12 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
cmdk@1.1.1(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
transitivePeerDependencies:
|
||||
@@ -3356,8 +3379,16 @@ snapshots:
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-force@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-quadtree: 3.0.1
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
@@ -3366,6 +3397,8 @@ snapshots:
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
|
||||
d3-quadtree@3.0.1: {}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
@@ -3813,9 +3846,9 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
vaul@1.1.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// 核心股历史数据获取工具
|
||||
import { getApiBaseUrl } from "@/lib/api-client";
|
||||
|
||||
export interface ActiveCoreStock {
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
coverCount: number | null; // 覆盖题材数
|
||||
dailyGains: Record<string, number | null>; // 日期 -> 当日涨幅(可空)
|
||||
appearCount: number;
|
||||
lastAppear: string | null;
|
||||
daysSinceLastAppear: number; // 最近上榜距窗口最新交易日的自然日差
|
||||
themes: { theme_code: string; theme_name: string }[]; // 所属题材列表
|
||||
}
|
||||
|
||||
export interface ActiveCoreStocksResponse {
|
||||
dates: string[];
|
||||
stocks: ActiveCoreStock[];
|
||||
}
|
||||
|
||||
export interface CoreStockHistoryItem {
|
||||
id: number;
|
||||
trade_date: string;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
f3: number | null;
|
||||
cover_count: number | null;
|
||||
rank: number;
|
||||
themes: { theme_code: string; theme_name: string }[];
|
||||
}
|
||||
|
||||
export interface CoreStockHistoryResponse {
|
||||
date: string;
|
||||
items: CoreStockHistoryItem[];
|
||||
}
|
||||
|
||||
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
|
||||
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return { dates: [], stocks: [] };
|
||||
return resp.json();
|
||||
} catch (err) {
|
||||
console.error("[core-stock-api] 获取活跃核心股失败:", err);
|
||||
return { dates: [], stocks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取指定交易日核心股(含所属题材) */
|
||||
export async function fetchCoreStockHistory(date: string): Promise<CoreStockHistoryResponse | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
return resp.json();
|
||||
} catch (err) {
|
||||
console.error("[core-stock-api] 获取核心股历史失败:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+8
-48
@@ -18,6 +18,11 @@ export interface StockQuote {
|
||||
time: string;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
turnoverRate: number; // 换手率%
|
||||
pe: number; // 市盈率
|
||||
pb: number; // 市净率
|
||||
totalMarketCap: number; // 总市值
|
||||
circulatingMarketCap: number; // 流通市值
|
||||
}
|
||||
|
||||
export interface KLineData {
|
||||
@@ -92,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" };
|
||||
}
|
||||
@@ -425,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): Promise<SectorItem[]> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/sectors?type=${type}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
if (!resp.ok) return [];
|
||||
const result: SectorResponse = await resp.json();
|
||||
return result.data || [];
|
||||
} catch (err) {
|
||||
console.error("[stock-api] 获取板块数据失败:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// 题材数据获取工具:通过 Python 后端代理调用东方财富题材接口
|
||||
import { getApiBaseUrl } from "@/lib/api-client";
|
||||
|
||||
/* ── 题材列表 ── */
|
||||
|
||||
export interface ThemeItem {
|
||||
themeCode: string;
|
||||
themeName: string;
|
||||
securityName: string | null; // 领涨股名称(无领涨股的题材为 null)
|
||||
securityCode: string | null; // 领涨股代码(无领涨股的题材为 null)
|
||||
codeWithSuffix: string;
|
||||
hotRank: number; // 热度排名
|
||||
f3: number | null; // 领涨股涨幅
|
||||
bf3: number | null; // 题材涨幅
|
||||
hotValue: number; // 热度值
|
||||
hotValueUpLimit: number; // 热度上限
|
||||
strengthValue: number | null; // 强度值
|
||||
fex5: number | null; // 成交额
|
||||
fex3: number | null;
|
||||
label: string | null; // 标签(如"超级爆点")
|
||||
}
|
||||
|
||||
export type ThemeSortField = 1 | 3 | 4 | 5; // 1=涨幅 3=强度 4=热度排名 5=成交额
|
||||
|
||||
export interface ThemeListResponse {
|
||||
data: ThemeItem[];
|
||||
count: number;
|
||||
sort_field: number;
|
||||
asc: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全部题材列表
|
||||
* @param sortField 1=涨幅 3=强度 4=热度排名 5=成交额
|
||||
* @param asc true=升序
|
||||
*/
|
||||
export async function fetchThemes(sortField: ThemeSortField = 1, asc: boolean = false): Promise<ThemeItem[]> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/themes?sort_field=${sortField}&asc=${asc}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return [];
|
||||
const result: ThemeListResponse = await resp.json();
|
||||
return result.data || [];
|
||||
} catch (err) {
|
||||
console.error("[theme-api] 获取题材列表失败:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 题材详情 ── */
|
||||
|
||||
export interface ThemeHotEvent {
|
||||
newsTitle: string | null;
|
||||
newsSummary: string | null;
|
||||
newsMediaName: string | null;
|
||||
newsPublishTimeFormat: string | null;
|
||||
newsCode: string | null;
|
||||
}
|
||||
|
||||
export interface ThemeNews {
|
||||
newsCode: string;
|
||||
newsTitle: string;
|
||||
newsMediaName: string;
|
||||
newsPublishTime: number | null;
|
||||
}
|
||||
|
||||
export interface ThemeBaseInfo {
|
||||
themeCode: string;
|
||||
themeName: string;
|
||||
introduction: string;
|
||||
explainImgUrl: string | null;
|
||||
themeLevel: number;
|
||||
isShowRank: number;
|
||||
}
|
||||
|
||||
export interface ThemeDetail {
|
||||
baseInfo: ThemeBaseInfo;
|
||||
hotEvent: ThemeHotEvent | null;
|
||||
eventHistory: ThemeNews[];
|
||||
topicId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题材详情(简介 + 热点事件 + 相关新闻)
|
||||
*/
|
||||
export async function fetchThemeDetail(themeCode: string): Promise<ThemeDetail | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/themes/${themeCode}/detail`;
|
||||
|
||||
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 ThemeStockKeyword {
|
||||
keywordCode: string;
|
||||
keyword: string;
|
||||
introduction: string;
|
||||
}
|
||||
|
||||
export interface ThemeStock {
|
||||
securityName: string;
|
||||
securityCode: string;
|
||||
codeSuffix: string;
|
||||
f2: number; // 现价
|
||||
f3: number; // 涨跌幅%
|
||||
f5: number; // 成交量
|
||||
f6: number; // 成交额
|
||||
f8: number; // 换手率%
|
||||
f20: number; // 总市值
|
||||
f21: number; // 流通市值
|
||||
f62: number; // 主力净流入
|
||||
f100: string; // 所属行业
|
||||
f265: string; // 板块代码
|
||||
label: string | null; // 涨停标签
|
||||
rank: number;
|
||||
dragonStockLabel: number;
|
||||
keywordList: ThemeStockKeyword[]; // 入选理由
|
||||
}
|
||||
|
||||
export interface ThemeStatistic {
|
||||
f3: number | null; // 板块涨幅
|
||||
f104: number | null; // 上涨家数
|
||||
f105: number | null; // 下跌家数
|
||||
f106: number | null; // 平盘家数
|
||||
fex5: number | null; // 板块成交额
|
||||
}
|
||||
|
||||
export interface ThemeStocksResponse {
|
||||
data: ThemeStock[];
|
||||
statistic: ThemeStatistic;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题材下全部相关股票
|
||||
*/
|
||||
export async function fetchThemeStocks(themeCode: string): Promise<ThemeStocksResponse | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/themes/${themeCode}/stocks`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch (err) {
|
||||
console.error("[theme-api] 获取题材股票失败:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 题材相关新闻(分页) ── */
|
||||
|
||||
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 {
|
||||
themeCode: string;
|
||||
themeName: string;
|
||||
stockCount: number; // 题材内股票数
|
||||
bf3: number | null; // 题材涨幅
|
||||
}
|
||||
|
||||
export interface GraphStock {
|
||||
securityCode: string;
|
||||
securityName: string;
|
||||
coverCount: number; // 覆盖题材数(穿透强度)
|
||||
f3: number | null; // 涨幅
|
||||
f2: number | null; // 现价
|
||||
f62: number | null; // 主力净流入
|
||||
f100: string; // 行业
|
||||
themeCodes: string[]; // 所属题材代码
|
||||
}
|
||||
|
||||
export interface GraphStats {
|
||||
themeCount: number;
|
||||
stockCount: number;
|
||||
coreCount: number; // 覆盖≥2 的核心股数
|
||||
maxCover: number; // 最大覆盖题材数
|
||||
}
|
||||
|
||||
export interface ThemeGraph {
|
||||
themes: GraphTheme[];
|
||||
stocks: GraphStock[];
|
||||
stats: GraphStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从股票覆盖题材重建关系边(后端不再下发 edges,体积减半以上)
|
||||
* 返回 d3-force 可直接使用的 source/target 节点 id("t:题材code" / "s:股票code")
|
||||
*/
|
||||
export function buildEdgesFromStocks(stocks: GraphStock[]): { source: string; target: string }[] {
|
||||
const edges: { source: string; target: string }[] = [];
|
||||
for (const s of stocks) {
|
||||
const target = `s:${s.securityCode}`;
|
||||
for (const tc of s.themeCodes) edges.push({ source: `t:${tc}`, target });
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热点穿透图数据(题材-股票 M:N 网状关系)
|
||||
* @param sortField 1=涨幅 4=热度
|
||||
* @param top 题材数量
|
||||
* @param limit 下发的股票节点上限(按穿透度取前 N 只)
|
||||
*/
|
||||
export async function fetchThemeGraph(
|
||||
sortField: 1 | 4 = 1,
|
||||
top: number = 30,
|
||||
limit: number = 1000,
|
||||
): Promise<ThemeGraph | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/themes/graph?sort_field=${sortField}&top=${top}&limit=${limit}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch (err) {
|
||||
console.error("[theme-api] 获取热点穿透图数据失败:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -1,14 +1,17 @@
|
||||
// 应用入口:样式在 ./styles.css(Tailwind v4 + design token),路由见 ./router.tsx
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { getRouter } from "./router";
|
||||
import "./styles.css";
|
||||
|
||||
const router = getRouter();
|
||||
const { router, queryClient } = getRouter();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
+93
-17
@@ -9,14 +9,27 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SectorsRouteImport } from './routes/sectors'
|
||||
import { Route as ThemesRouteImport } from './routes/themes'
|
||||
import { Route as HotMapRouteImport } from './routes/hot-map'
|
||||
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
|
||||
import { Route as StockCodeRouteImport } from './routes/stock.$code'
|
||||
import { Route as ShareCodeRouteImport } from './routes/share.$code'
|
||||
|
||||
const SectorsRoute = SectorsRouteImport.update({
|
||||
id: '/sectors',
|
||||
path: '/sectors',
|
||||
const ThemesRoute = ThemesRouteImport.update({
|
||||
id: '/themes',
|
||||
path: '/themes',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const HotMapRoute = HotMapRouteImport.update({
|
||||
id: '/hot-map',
|
||||
path: '/hot-map',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CoreStocksRoute = CoreStocksRouteImport.update({
|
||||
id: '/core-stocks',
|
||||
path: '/core-stocks',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
@@ -24,6 +37,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ThemeCodeRoute = ThemeCodeRouteImport.update({
|
||||
id: '/theme/$code',
|
||||
path: '/theme/$code',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const StockCodeRoute = StockCodeRouteImport.update({
|
||||
id: '/stock/$code',
|
||||
path: '/stock/$code',
|
||||
@@ -37,45 +55,93 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/sectors': typeof SectorsRoute
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
'/share/$code': typeof ShareCodeRoute
|
||||
'/stock/$code': typeof StockCodeRoute
|
||||
'/theme/$code': typeof ThemeCodeRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/sectors': typeof SectorsRoute
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
'/share/$code': typeof ShareCodeRoute
|
||||
'/stock/$code': typeof StockCodeRoute
|
||||
'/theme/$code': typeof ThemeCodeRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/sectors': typeof SectorsRoute
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
'/share/$code': typeof ShareCodeRoute
|
||||
'/stock/$code': typeof StockCodeRoute
|
||||
'/theme/$code': typeof ThemeCodeRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/sectors' | '/share/$code' | '/stock/$code'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/core-stocks'
|
||||
| '/hot-map'
|
||||
| '/themes'
|
||||
| '/share/$code'
|
||||
| '/stock/$code'
|
||||
| '/theme/$code'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/sectors' | '/share/$code' | '/stock/$code'
|
||||
id: '__root__' | '/' | '/sectors' | '/share/$code' | '/stock/$code'
|
||||
to:
|
||||
| '/'
|
||||
| '/core-stocks'
|
||||
| '/hot-map'
|
||||
| '/themes'
|
||||
| '/share/$code'
|
||||
| '/stock/$code'
|
||||
| '/theme/$code'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/core-stocks'
|
||||
| '/hot-map'
|
||||
| '/themes'
|
||||
| '/share/$code'
|
||||
| '/stock/$code'
|
||||
| '/theme/$code'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
SectorsRoute: typeof SectorsRoute
|
||||
CoreStocksRoute: typeof CoreStocksRoute
|
||||
HotMapRoute: typeof HotMapRoute
|
||||
ThemesRoute: typeof ThemesRoute
|
||||
ShareCodeRoute: typeof ShareCodeRoute
|
||||
StockCodeRoute: typeof StockCodeRoute
|
||||
ThemeCodeRoute: typeof ThemeCodeRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/sectors': {
|
||||
id: '/sectors'
|
||||
path: '/sectors'
|
||||
fullPath: '/sectors'
|
||||
preLoaderRoute: typeof SectorsRouteImport
|
||||
'/themes': {
|
||||
id: '/themes'
|
||||
path: '/themes'
|
||||
fullPath: '/themes'
|
||||
preLoaderRoute: typeof ThemesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/hot-map': {
|
||||
id: '/hot-map'
|
||||
path: '/hot-map'
|
||||
fullPath: '/hot-map'
|
||||
preLoaderRoute: typeof HotMapRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/core-stocks': {
|
||||
id: '/core-stocks'
|
||||
path: '/core-stocks'
|
||||
fullPath: '/core-stocks'
|
||||
preLoaderRoute: typeof CoreStocksRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
@@ -85,6 +151,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/theme/$code': {
|
||||
id: '/theme/$code'
|
||||
path: '/theme/$code'
|
||||
fullPath: '/theme/$code'
|
||||
preLoaderRoute: typeof ThemeCodeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/stock/$code': {
|
||||
id: '/stock/$code'
|
||||
path: '/stock/$code'
|
||||
@@ -104,9 +177,12 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
SectorsRoute: SectorsRoute,
|
||||
CoreStocksRoute: CoreStocksRoute,
|
||||
HotMapRoute: HotMapRoute,
|
||||
ThemesRoute: ThemesRoute,
|
||||
ShareCodeRoute: ShareCodeRoute,
|
||||
StockCodeRoute: StockCodeRoute,
|
||||
ThemeCodeRoute: ThemeCodeRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
+1
-1
@@ -20,5 +20,5 @@ export const getRouter = () => {
|
||||
defaultPreloadStaleTime: 0,
|
||||
});
|
||||
|
||||
return router;
|
||||
return { router, queryClient };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Fragment } from "react";
|
||||
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
|
||||
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/core-stocks")({
|
||||
component: CoreStocksPage,
|
||||
});
|
||||
|
||||
/** 格式化涨幅,红涨绿跌 */
|
||||
function formatGain(v: number | null | undefined): string {
|
||||
if (v == null) return "·";
|
||||
const s = v >= 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
|
||||
return s;
|
||||
}
|
||||
|
||||
function CoreStocksPage() {
|
||||
const { data, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: ["core-stocks", "active"],
|
||||
queryFn: fetchActiveCoreStocks,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const dates = data?.dates ?? [];
|
||||
const stocks = data?.stocks ?? [];
|
||||
|
||||
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="/hot-map" className="hover:opacity-70 transition-opacity" aria-label="返回">
|
||||
<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>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
活跃热点股(最近 10 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
||||
) : dates.length === 0 || stocks.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-16">
|
||||
暂无数据,数据将在每日收盘后自动采集
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th scope="col" className="px-3 py-2 text-left font-medium whitespace-nowrap">股票</th>
|
||||
{dates.map((d) => (
|
||||
<th key={d} scope="col" title={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
|
||||
{d.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium" title="覆盖题材数">题材数</th>
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium whitespace-nowrap">最近上榜</th>
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium">上榜</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stocks.map((s) => (
|
||||
<Fragment key={s.stockCode}>
|
||||
<tr className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-3 py-1.5 whitespace-nowrap">
|
||||
<Link
|
||||
to="/stock/$code"
|
||||
params={{ code: s.stockCode }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{s.stockName}
|
||||
</Link>
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
|
||||
</td>
|
||||
{dates.map((d) => {
|
||||
const g = s.dailyGains[d];
|
||||
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
|
||||
return (
|
||||
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
|
||||
{formatGain(g)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
|
||||
{s.coverCount ?? "·"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
|
||||
{s.lastAppear ? s.lastAppear.slice(5) : "·"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||
<span className="inline-flex items-center gap-0.5 text-orange-500">
|
||||
<Flame className="h-3 w-3" />
|
||||
{s.appearCount}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b bg-muted/20">
|
||||
<td colSpan={dates.length + 4} className="px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground mb-1">所属题材</div>
|
||||
{s.themes && s.themes.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{s.themes.map((t) => (
|
||||
<Link
|
||||
key={t.theme_code}
|
||||
to="/theme/$code"
|
||||
params={{ code: t.theme_code }}
|
||||
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
{t.theme_name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">暂无题材数据</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+18
-10
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2 } from "lucide-react";
|
||||
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
@@ -211,12 +211,20 @@ function Index() {
|
||||
A股走势追踪
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
|
||||
<Link to="/sectors">
|
||||
<Button variant="outline" size="sm" className="mt-3 gap-1.5 text-xs">
|
||||
<TrendingUp className="h-3.5 w-3.5" />
|
||||
板块资金流向
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
<Link to="/themes">
|
||||
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
题材热点
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/hot-map">
|
||||
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
热点穿透
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6 md:mb-8 shadow-lg">
|
||||
@@ -517,7 +525,7 @@ function CollectionCard({
|
||||
<p className="text-sm text-muted-foreground text-center py-4">暂无股票</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{stocks.slice(0, 10).map((stock) => (
|
||||
{stocks.slice(0, 20).map((stock) => (
|
||||
<StockRowItem
|
||||
key={stock.id}
|
||||
stock={stock}
|
||||
@@ -526,8 +534,8 @@ function CollectionCard({
|
||||
swipeResetKey={swipeResetKey}
|
||||
/>
|
||||
))}
|
||||
{stocks.length > 10 && (
|
||||
<p className="text-xs text-muted-foreground text-center pt-1">还有 {stocks.length - 10} 只股票...</p>
|
||||
{stocks.length > 20 && (
|
||||
<p className="text-xs text-muted-foreground text-center pt-1">还有 {stocks.length - 20} 只股票...</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { fetchSectors, type SectorItem, type SectorType } from "@/lib/stock-api";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ArrowLeft, TrendingUp, TrendingDown, RefreshCw } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/sectors")({
|
||||
component: SectorsPage,
|
||||
});
|
||||
|
||||
const TABS: { key: SectorType; label: string }[] = [
|
||||
{ key: "industry", label: "行业板块" },
|
||||
{ key: "concept", label: "概念板块" },
|
||||
];
|
||||
|
||||
type SortMode = "mainNetInflow" | "changePercent";
|
||||
|
||||
function SectorsPage() {
|
||||
const [tab, setTab] = useState<SectorType>("industry");
|
||||
const [data, setData] = useState<SectorItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sort, setSort] = useState<SortMode>("mainNetInflow");
|
||||
|
||||
const loadData = (t: SectorType) => {
|
||||
setLoading(true);
|
||||
fetchSectors(t).then((items) => {
|
||||
setData(items);
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData(tab);
|
||||
}, [tab]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...data].sort((a, b) => {
|
||||
if (sort === "mainNetInflow") return b.mainNetInflow - a.mainNetInflow;
|
||||
return (b.changePercent ?? 0) - (a.changePercent ?? 0);
|
||||
});
|
||||
}, [data, sort]);
|
||||
|
||||
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={() => loadData(tab)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tab + Sorting 切换 */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-4 flex items-center gap-2">
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-1 flex-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(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 className="flex gap-0.5 text-xs border rounded overflow-hidden shrink-0">
|
||||
<button onClick={() => setSort("mainNetInflow")}
|
||||
className={`px-2 py-1 transition-colors ${
|
||||
sort === "mainNetInflow"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>主力</button>
|
||||
<button onClick={() => setSort("changePercent")}
|
||||
className={`px-2 py-1 transition-colors ${
|
||||
sort === "changePercent"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>涨幅</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据信息 */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
含 {sorted.length} 个板块 · 按{sort === "mainNetInflow" ? "主力净流入" : "涨跌幅"}降序
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 卡片网格 */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||
{loading ? (
|
||||
<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-32" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{sorted.map((item) => (
|
||||
<SectorBlock key={item.code} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatInflow(val: number | null | undefined): string {
|
||||
if (val == null) return "--";
|
||||
const abs = Math.abs(val);
|
||||
if (abs >= 1e8) return (val / 1e8).toFixed(2) + "亿";
|
||||
if (abs >= 1e4) return (val / 1e4).toFixed(0) + "万";
|
||||
return val.toFixed(0);
|
||||
}
|
||||
|
||||
function FundFlowBar({ value, maxAbs }: { value: number | null | undefined; maxAbs: number }) {
|
||||
if (value == null) return null;
|
||||
const pct = maxAbs > 0 ? (value / maxAbs) * 100 : 0;
|
||||
const isPos = value >= 0;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
isPos ? "bg-red-500/60" : "bg-green-500/60"
|
||||
}`}
|
||||
style={{ width: `${Math.min(Math.abs(pct), 100)}%`, marginLeft: isPos ? "50%" : undefined }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium tabular-nums w-14 text-right ${
|
||||
isPos ? "text-red-500" : "text-green-500"
|
||||
}`}>
|
||||
{formatInflow(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectorBlock({ item }: { item: SectorItem }) {
|
||||
const inflow = item.mainNetInflow;
|
||||
const isPositive = inflow >= 0;
|
||||
const change = item.changePercent;
|
||||
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 (
|
||||
<Card className="rounded-xl hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{/* 板块名称 */}
|
||||
<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 ? "+" : ""}
|
||||
{change.toFixed(2)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatInflow(item.turnover)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 主力净流入 */}
|
||||
<div className="pt-1.5 border-t border-border/40">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10px] text-muted-foreground">主力净流入</span>
|
||||
<span className={`text-xs font-bold tabular-nums ${
|
||||
isPositive ? "text-red-500" : "text-green-500"
|
||||
}`}>
|
||||
{isPositive ? "+" : ""}{formatInflow(inflow)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 资金流向明细条 */}
|
||||
<div className="space-y-0.5">
|
||||
<FundFlowBar value={item.superLargeInflow} maxAbs={maxAbs} />
|
||||
<FundFlowBar value={item.largeInflow} maxAbs={maxAbs} />
|
||||
<FundFlowBar value={item.mediumInflow} maxAbs={maxAbs} />
|
||||
<FundFlowBar value={item.smallInflow} maxAbs={maxAbs} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -63,8 +63,14 @@ interface StockInfo {
|
||||
addedPrice: number | null;
|
||||
outerDisk: number; // 外盘(手)
|
||||
innerDisk: number; // 内盘(手)
|
||||
volume: number; // 成交量(股)
|
||||
amount: number; // 成交额(元)
|
||||
quoteDate: string; // 行情日期 YYYY-MM-DD
|
||||
turnoverRate: number; // 换手率%
|
||||
pe: number; // 市盈率
|
||||
pb: number; // 市净率
|
||||
totalMarketCap: number; // 总市值
|
||||
circulatingMarketCap: number; // 流通市值
|
||||
}
|
||||
|
||||
function StockDetail() {
|
||||
@@ -155,6 +161,12 @@ function StockDetail() {
|
||||
innerDisk: quote.innerDisk || 0,
|
||||
amount: (quote.amount || 0) * 10000,
|
||||
quoteDate: quote.date,
|
||||
volume: quote.volume || 0,
|
||||
turnoverRate: quote.turnoverRate ?? 0,
|
||||
pe: quote.pe ?? 0,
|
||||
pb: quote.pb ?? 0,
|
||||
totalMarketCap: quote.totalMarketCap ?? 0,
|
||||
circulatingMarketCap: quote.circulatingMarketCap ?? 0,
|
||||
});
|
||||
|
||||
// 基础信息已就绪,结束主loading,先渲染页面框架
|
||||
@@ -267,6 +279,14 @@ function StockDetail() {
|
||||
negativeDays: negative,
|
||||
};
|
||||
}, [fundFlowSlice]);
|
||||
|
||||
// 从财务数据中提取最新ROE(加权净资产收益率)
|
||||
const latestROE = useMemo(() => {
|
||||
if (!financialData?.data?.length) return null;
|
||||
const latest = financialData.data[0];
|
||||
const roe = latest.indicators["加权净资产收益率"];
|
||||
return typeof roe === "number" ? roe : null;
|
||||
}, [financialData]);
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -331,12 +351,8 @@ function StockDetail() {
|
||||
ticks.push(added);
|
||||
}
|
||||
ticks.push(last);
|
||||
return [...new Set(ticks)].sort((a, b) => {
|
||||
// 按日期排序(MM/DD格式需按MM和DD比较)
|
||||
const [am, ad] = a.split('/').map(Number);
|
||||
const [bm, bd] = b.split('/').map(Number);
|
||||
return am - bm || ad - bd;
|
||||
});
|
||||
// 保持chartData的原始顺序(已按日期升序排列),不做二次排序
|
||||
return [...new Set(ticks)];
|
||||
})();
|
||||
|
||||
// 东方财富市场标识:0=深圳(000/002/300), 1=上海(60), 6=科创板(688)
|
||||
@@ -447,6 +463,48 @@ function StockDetail() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 行情指标合并到顶部卡片 */}
|
||||
<div className="mt-4 md:mt-5 pt-4 md:pt-5 border-t border-border/50">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 md:gap-3">
|
||||
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5">市盈率(PE)</p>
|
||||
<p className="text-sm md:text-base font-semibold">{stockInfo.pe > 0 ? stockInfo.pe.toFixed(2) : '-'}</p>
|
||||
</div>
|
||||
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5">市净率(PB)</p>
|
||||
<p className="text-sm md:text-base font-semibold">{stockInfo.pb > 0 ? stockInfo.pb.toFixed(2) : '-'}</p>
|
||||
</div>
|
||||
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5">ROE</p>
|
||||
<p className={`text-sm md:text-base font-semibold ${latestROE !== null ? (latestROE > 0 ? 'text-red-500' : 'text-green-500') : ''}`}>
|
||||
{latestROE !== null ? `${latestROE.toFixed(2)}%` : '加载中...'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5">换手率</p>
|
||||
<p className="text-sm md:text-base font-semibold">{stockInfo.turnoverRate > 0 ? `${stockInfo.turnoverRate}%` : '-'}</p>
|
||||
</div>
|
||||
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5">总手</p>
|
||||
<p className="text-sm md:text-base font-semibold">
|
||||
{stockInfo.volume > 0 ? `${(stockInfo.volume / 100).toLocaleString()}手` : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5">成交额</p>
|
||||
<p className="text-sm md:text-base font-semibold">{formatMoney(stockInfo.amount)}</p>
|
||||
</div>
|
||||
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5">总市值</p>
|
||||
<p className="text-sm md:text-base font-semibold">{stockInfo.totalMarketCap > 0 ? formatMoney(stockInfo.totalMarketCap) : '-'}</p>
|
||||
</div>
|
||||
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5">流通市值</p>
|
||||
<p className="text-sm md:text-base font-semibold">{stockInfo.circulatingMarketCap > 0 ? formatMoney(stockInfo.circulatingMarketCap) : '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -653,6 +711,8 @@ function StockDetail() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Daily Data Table - Independent from fund flow */}
|
||||
{chartData.length > 0 && (
|
||||
<Card className="mt-4 md:mt-6 shadow-lg">
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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";
|
||||
import {
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Flame,
|
||||
Newspaper,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/theme/$code")({
|
||||
component: ThemeDetailPage,
|
||||
});
|
||||
|
||||
function ThemeDetailPage() {
|
||||
const { code } = Route.useParams();
|
||||
|
||||
const detailQ = useQuery({
|
||||
queryKey: ["themeDetail", code],
|
||||
queryFn: () => fetchThemeDetail(code),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
const stocksQ = useQuery({
|
||||
queryKey: ["themeStocks", code],
|
||||
queryFn: () => fetchThemeStocks(code),
|
||||
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;
|
||||
const isFetching = detailQ.isFetching || stocksQ.isFetching;
|
||||
|
||||
const detail = detailQ.data;
|
||||
const stocks = stocksQ.data?.data ?? [];
|
||||
const statistic = stocksQ.data?.statistic;
|
||||
const total = stocksQ.data?.total ?? 0;
|
||||
|
||||
const refresh = () => {
|
||||
detailQ.refetch();
|
||||
stocksQ.refetch();
|
||||
quoteQ.refetch();
|
||||
setNewsPage(1);
|
||||
newsQ.refetch();
|
||||
};
|
||||
|
||||
const baseInfo = detail?.baseInfo;
|
||||
const hotEvent = detail?.hotEvent;
|
||||
|
||||
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-3xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Link to="/themes" className="hover:opacity-70 transition-opacity shrink-0">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-base font-semibold truncate">{baseInfo?.themeName ?? "题材详情"}</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-3xl mx-auto px-4 py-4 pb-10 space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
||||
<div className="animate-pulse rounded-xl bg-muted h-24" />
|
||||
<div className="animate-pulse rounded-xl bg-muted h-64" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<p className="text-sm text-muted-foreground">数据加载失败</p>
|
||||
<button onClick={refresh} className="text-xs text-primary hover:underline">
|
||||
点击重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ── 题材简介 ── */}
|
||||
{baseInfo?.introduction && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Info className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">题材简介</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{baseInfo.introduction}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── 热点事件 ── */}
|
||||
{hotEvent?.newsTitle && (
|
||||
<Card className="border-orange-500/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Flame className="h-4 w-4 text-orange-500" />
|
||||
<h2 className="text-sm font-semibold">热点事件</h2>
|
||||
{hotEvent.newsMediaName && (
|
||||
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
|
||||
{hotEvent.newsMediaName}
|
||||
{hotEvent.newsPublishTimeFormat ? ` · ${hotEvent.newsPublishTimeFormat}` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium leading-snug">{hotEvent.newsTitle}</p>
|
||||
{hotEvent.newsSummary && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mt-1.5 line-clamp-3">
|
||||
{hotEvent.newsSummary}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── 板块统计 ── */}
|
||||
<StatBar
|
||||
f3={statistic?.f3}
|
||||
up={statistic?.f104}
|
||||
down={statistic?.f105}
|
||||
flat={statistic?.f106}
|
||||
fex5={statistic?.fex5}
|
||||
total={total}
|
||||
strength={quoteQ.data?.strengthValue ?? null}
|
||||
hotValue={quoteQ.data?.hotValue ?? 0}
|
||||
hotValueUpLimit={quoteQ.data?.hotValueUpLimit ?? 0}
|
||||
/>
|
||||
|
||||
{/* ── 相关新闻(分页加载) ── */}
|
||||
{newsItems.length > 0 && (
|
||||
<NewsList
|
||||
items={newsItems}
|
||||
total={newsQ.data?.total ?? 0}
|
||||
loadingMore={newsQ.isFetching && newsPage > 1}
|
||||
onLoadMore={() => setNewsPage((p) => p + 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 相关股票 ── */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<h2 className="text-sm font-semibold">相关股票</h2>
|
||||
<span className="text-[10px] text-muted-foreground">共 {total} 只</span>
|
||||
</div>
|
||||
{stocks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-sm text-muted-foreground">
|
||||
暂无相关股票
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{stocks.map((s) => (
|
||||
<ThemeStockRow key={s.securityCode} stock={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
板块统计条
|
||||
============================================================ */
|
||||
function StatBar({
|
||||
f3,
|
||||
up,
|
||||
down,
|
||||
flat,
|
||||
fex5,
|
||||
total,
|
||||
strength,
|
||||
hotValue,
|
||||
hotValueUpLimit,
|
||||
}: {
|
||||
f3: number | null | undefined;
|
||||
up: number | null | undefined;
|
||||
down: number | null | undefined;
|
||||
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">
|
||||
<div className="grid grid-cols-4 divide-x divide-border/50 text-center">
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground">板块涨幅</p>
|
||||
<p className={`text-sm font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
|
||||
{f3 != null ? `${isPos ? "+" : ""}${f3.toFixed(2)}%` : "--"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground">上涨</p>
|
||||
<p className="text-sm font-bold tabular-nums text-red-500">{up ?? "--"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground">下跌</p>
|
||||
<p className="text-sm font-bold tabular-nums text-green-500">{down ?? "--"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-muted-foreground">成交额</p>
|
||||
<p className="text-xs font-semibold tabular-nums">{fex5 != null ? formatMoney(fex5) : "--"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{(flat != null && flat > 0) && (
|
||||
<p className="text-[10px] text-muted-foreground text-center mt-1.5">
|
||||
平盘 {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,
|
||||
total,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: {
|
||||
items: ThemeNewsItem[];
|
||||
total: number;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
}) {
|
||||
const hasMore = items.length < total;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<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">共 {total} 条</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{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.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""}
|
||||
{n.commentCount > 0 && <span className="ml-1">· {n.commentCount} 评论</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5 disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? "加载中…" : "加载更多"}
|
||||
{!loadingMore && <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
相关股票行
|
||||
============================================================ */
|
||||
function ThemeStockRow({ stock }: { stock: ThemeStock }) {
|
||||
const [showReason, setShowReason] = useState(true); // 入选理由默认展开
|
||||
const board = getStockBoard(stock.securityCode);
|
||||
const isPos = stock.f3 >= 0;
|
||||
const reasons = stock.keywordList ?? [];
|
||||
|
||||
// 换手率:接口返回放大 100 倍的值(如 3733 = 37.33%)
|
||||
const turnoverRate = stock.f8 > 100 ? stock.f8 / 100 : stock.f8;
|
||||
|
||||
return (
|
||||
<Link to="/stock/$code" params={{ code: stock.securityCode }} className="block">
|
||||
<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-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{stock.securityName}</p>
|
||||
{board.label && (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold leading-none shrink-0 ${board.className}`}
|
||||
>
|
||||
{board.label}
|
||||
</span>
|
||||
)}
|
||||
{stock.label && (
|
||||
<span className="shrink-0 text-[9px] font-medium text-orange-500 bg-orange-500/10 border border-orange-500/30 rounded px-1 py-0.5">
|
||||
{stock.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] text-muted-foreground">现价</p>
|
||||
<p className="text-sm font-semibold tabular-nums">{stock.f2.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="text-right min-w-[56px]">
|
||||
<p className="text-[10px] text-muted-foreground">涨跌</p>
|
||||
<p className={`text-sm font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
|
||||
{isPos ? "+" : ""}
|
||||
{stock.f3.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 行业 + 换手 + 主力 + 成交额 */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{stock.f100 && (
|
||||
<span className="truncate bg-muted rounded px-1.5 py-0.5 text-[10px]">{stock.f100}</span>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums">换手 {turnoverRate.toFixed(2)}%</span>
|
||||
<span className="shrink-0 tabular-nums">主力 {formatMoney(stock.f62)}</span>
|
||||
<span className="shrink-0 tabular-nums ml-auto">成交 {formatMoney(stock.f6)}</span>
|
||||
</div>
|
||||
|
||||
{/* 入选理由 */}
|
||||
{reasons.length > 0 && (
|
||||
<div className="border-t border-border/40 pt-1.5">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowReason((v) => !v);
|
||||
}}
|
||||
className="text-[10px] text-primary hover:underline inline-flex items-center gap-0.5"
|
||||
>
|
||||
入选理由
|
||||
{showReason ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
{showReason && (
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed mt-1">
|
||||
{reasons.map((r) => r.introduction).filter(Boolean).join(" ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchThemes, type ThemeItem, type ThemeSortField } from "@/lib/theme-api";
|
||||
import { getStockBoard } from "@/lib/stock-api";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Flame,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/themes")({
|
||||
component: ThemesPage,
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
排序维度(sortField 与后端/东方财富对齐)
|
||||
============================================================ */
|
||||
const SORTS: { key: ThemeSortField; label: string }[] = [
|
||||
{ key: 1, label: "涨幅" },
|
||||
{ key: 3, label: "强度" },
|
||||
{ key: 4, label: "热度" },
|
||||
{ key: 5, label: "成交额" },
|
||||
];
|
||||
|
||||
function ThemesPage() {
|
||||
const [sortField, setSortField] = useState<ThemeSortField>(1);
|
||||
const [asc, setAsc] = useState(false); // 默认降序
|
||||
|
||||
const { data: themes, isLoading, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: ["themes", sortField, asc],
|
||||
queryFn: () => fetchThemes(sortField, asc),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const data = themes ?? [];
|
||||
|
||||
const toggleSort = (key: ThemeSortField) => {
|
||||
if (key === sortField) {
|
||||
setAsc((v) => !v);
|
||||
} else {
|
||||
setSortField(key);
|
||||
setAsc(false); // 切新维度默认降序
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/hot-map"
|
||||
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
热点穿透
|
||||
</Link>
|
||||
<Link
|
||||
to="/core-stocks"
|
||||
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
热点股
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 排序切换 + 统计 ── */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 flex items-center justify-between">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
共 {data.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">
|
||||
{SORTS.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => toggleSort(s.key)}
|
||||
className={`px-2.5 py-1 flex items-center gap-0.5 transition-colors ${
|
||||
sortField === s.key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
{sortField === s.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-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{Array.from({ length: 18 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-xl bg-muted h-32" />
|
||||
))}
|
||||
</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>
|
||||
) : data.length === 0 ? (
|
||||
<div className="text-center py-20 text-sm text-muted-foreground">暂无题材数据</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{data.map((item) => (
|
||||
<ThemeCard key={item.themeCode} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
数值格式化
|
||||
============================================================ */
|
||||
function fmt(val: number | null | undefined, digits = 2): string {
|
||||
if (val == null) return "--";
|
||||
return val.toFixed(digits);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
题材卡片
|
||||
============================================================ */
|
||||
function ThemeCard({ item }: { item: ThemeItem }) {
|
||||
const change = item.bf3;
|
||||
const hotPct =
|
||||
item.hotValueUpLimit > 0 ? Math.min((item.hotValue / item.hotValueUpLimit) * 100, 100) : 0;
|
||||
const stockBoard = getStockBoard(item.securityCode);
|
||||
|
||||
return (
|
||||
<Link to="/theme/$code" params={{ code: item.themeCode }} className="block">
|
||||
<Card className="rounded-xl hover:shadow-md transition-shadow h-full">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{/* 题材名 + 领涨标签 */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<p className="text-sm font-medium truncate" title={item.themeName}>
|
||||
{item.themeName}
|
||||
</p>
|
||||
{item.label && (
|
||||
<span className="shrink-0 text-[9px] font-medium text-orange-500 bg-orange-500/10 border border-orange-500/30 rounded px-1 py-0.5">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 热度进度条 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Flame className="h-3 w-3 text-orange-500 shrink-0" />
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
|
||||
style={{ width: `${Math.max(hotPct, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
|
||||
{item.hotValue}/{item.hotValueUpLimit}
|
||||
</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">
|
||||
{item.fex5 != null ? formatMoney(item.fex5) : "--"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<hr className="border-border/40" />
|
||||
|
||||
{/* 领涨股 + 强度 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">领涨</span>
|
||||
<span className="text-xs font-medium truncate">{item.securityName || "--"}</span>
|
||||
{stockBoard.label && (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold leading-none shrink-0 ${stockBoard.className}`}
|
||||
>
|
||||
{stockBoard.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{item.f3 != null && (
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${
|
||||
item.f3 >= 0 ? "text-red-500" : "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{item.f3 >= 0 ? "+" : ""}
|
||||
{fmt(item.f3)}%
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
强度 {item.strengthValue ?? "--"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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