Compare commits
71
Commits
e29e3d32fb
..
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 |
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,
|
value TEXT NOT NULL,
|
||||||
expires_at 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)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -6,7 +7,8 @@ from contextlib import asynccontextmanager
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from database import init_db
|
from database import init_db
|
||||||
from routes import stock, collections, shares, sectors
|
from routes import stock, collections, shares, themes, core_stocks
|
||||||
|
from services.daily_collector import collector_loop, cache_cleanup_loop
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -14,7 +16,18 @@ load_dotenv()
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
|
collector_task = asyncio.create_task(collector_loop())
|
||||||
|
cache_cleanup_task = asyncio.create_task(cache_cleanup_loop())
|
||||||
|
try:
|
||||||
yield
|
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)
|
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(stock.router, prefix="/api/stock")
|
||||||
app.include_router(collections.router, prefix="/api/collections")
|
app.include_router(collections.router, prefix="/api/collections")
|
||||||
app.include_router(shares.router, prefix="/api/share")
|
app.include_router(shares.router, prefix="/api/share")
|
||||||
app.include_router(sectors.router, prefix="/api/sectors")
|
app.include_router(themes.router, prefix="/api/themes")
|
||||||
|
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
||||||
|
|
||||||
# 生产模式:后端同时托管前端静态文件
|
# 生产模式:后端同时托管前端静态文件
|
||||||
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ uvicorn==0.30.0
|
|||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
python-dotenv==1.0.1
|
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
|
from fastapi import APIRouter, Query, HTTPException
|
||||||
import re
|
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
|
from models import StockSearchResult, StockQuote, KLineData, FundFlowData, FundFlowSummary, CompanyProfile, FinancialReportItem, FinancialDataResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -54,21 +54,28 @@ async def stock_history(
|
|||||||
if not re.match(r"^\d{6}$", code):
|
if not re.match(r"^\d{6}$", code):
|
||||||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
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)
|
em_klines = await eastmoney.fetch_kline_history(code, days)
|
||||||
if em_klines and len(em_klines) >= 2:
|
if em_klines and len(em_klines) >= 2:
|
||||||
return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"}
|
return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"}
|
||||||
|
|
||||||
# 降级1:腾讯(含涨跌幅)
|
# 降级2:腾讯(含涨跌幅)
|
||||||
tencent_klines = await tencent.fetch_history(code, days)
|
tencent_klines = await tencent.fetch_history(code, days)
|
||||||
if tencent_klines and len(tencent_klines) >= 2:
|
if tencent_klines and len(tencent_klines) >= 2:
|
||||||
return {"data": tencent_klines, "count": len(tencent_klines), "source": "tencent"}
|
return {"data": tencent_klines, "count": len(tencent_klines), "source": "tencent"}
|
||||||
|
|
||||||
# 降级2:新浪
|
# 降级3:新浪
|
||||||
sina_klines = await sina.fetch_history(code, days)
|
sina_klines = await sina.fetch_history(code, days)
|
||||||
if sina_klines:
|
if sina_klines:
|
||||||
return {"data": sina_klines, "count": len(sina_klines), "source": "sina"}
|
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:
|
if em_klines:
|
||||||
return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"}
|
return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"}
|
||||||
if tencent_klines:
|
if tencent_klines:
|
||||||
@@ -194,12 +201,20 @@ async def stock_fund_flow(
|
|||||||
if not data:
|
if not data:
|
||||||
raise HTTPException(status_code=404, detail="未获取到资金流向数据")
|
raise HTTPException(status_code=404, detail="未获取到资金流向数据")
|
||||||
|
|
||||||
# 从腾讯 K 线补充成交额/收盘价/涨跌幅
|
# 从腾讯 K 线补充收盘价/涨跌幅
|
||||||
kline_map = await tencent.fetch_kline_map(code, days)
|
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:
|
for d in data:
|
||||||
ki = kline_map.get(d["date"], {})
|
ki = kline_map.get(d["date"], {})
|
||||||
if d.get("turnover", 0) == 0:
|
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:
|
if d.get("closePrice", 0) == 0:
|
||||||
d["closePrice"] = ki.get("close", 0)
|
d["closePrice"] = ki.get("close", 0)
|
||||||
if d.get("changePercent", 0) == 0:
|
if d.get("changePercent", 0) == 0:
|
||||||
@@ -238,3 +253,19 @@ async def stock_fund_flow(
|
|||||||
"negativeDays": negative,
|
"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()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def set_cache(key: str, value: str, ttl_hours: int = 6):
|
def set_cache(key: str, value: str, ttl_hours: int = 6, ttl_seconds: int = 0):
|
||||||
"""写入缓存,过期时间 = now + ttl_hours"""
|
"""写入缓存,过期时间 = now + ttl_hours + ttl_seconds(支持秒级短 TTL)"""
|
||||||
expires_at = (datetime.now() + timedelta(hours=ttl_hours)).isoformat()
|
expires_at = (datetime.now() + timedelta(hours=ttl_hours, seconds=ttl_seconds)).isoformat()
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -56,3 +56,13 @@ def clean_expired():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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 分钟后再试
|
||||||
+88
-297
@@ -6,13 +6,11 @@ import httpx
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, time as dtime, timedelta, timezone
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from services.cache import get_cache, set_cache
|
from services.cache import get_cache, set_cache
|
||||||
|
|
||||||
from services.cache import get_cache, set_cache
|
|
||||||
|
|
||||||
|
|
||||||
# ---- API Key 轮询(MX 备选源用)----
|
# ---- API Key 轮询(MX 备选源用)----
|
||||||
|
|
||||||
@@ -206,6 +204,75 @@ async def fetch_mx_api(name: str, days: int) -> Optional[List[dict]]:
|
|||||||
return None
|
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:
|
def get_eastmoney_market(code: str) -> str:
|
||||||
"""获取东方财富格式的市场标识"""
|
"""获取东方财富格式的市场标识"""
|
||||||
if code.startswith("688"):
|
if code.startswith("688"):
|
||||||
@@ -214,296 +281,6 @@ def get_eastmoney_market(code: str) -> str:
|
|||||||
return "1"
|
return "1"
|
||||||
return "0"
|
return "0"
|
||||||
|
|
||||||
|
|
||||||
# ---- 板块数据 ----
|
|
||||||
|
|
||||||
# 从东方财富 bkzj/list.js 逆向的字段映射
|
|
||||||
# f62=主力净流入, f184=主力净流入占比
|
|
||||||
# f66=超大单净流入, f69=超大单净流入占比
|
|
||||||
# f72=大单净流入, f75=大单净流入占比
|
|
||||||
# f78=中单净流入, f81=中单净流入占比
|
|
||||||
# f84=小单净流入, f87=小单净流入占比
|
|
||||||
# f70=成交额
|
|
||||||
SECTOR_FIELDS = "f12,f14,f2,f3,f62,f184,f66,f69,f72,f75,f78,f81,f84,f87,f70"
|
|
||||||
|
|
||||||
# 东方财富板块类型映射
|
|
||||||
SECTOR_MEDIA_MAP = {
|
|
||||||
"industry": "m:90+s:4",
|
|
||||||
"concept": "m:90+t:3",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 东方财富 UT 令牌管理
|
|
||||||
_em_ut: str = "8dec03ba335b81bf4ebdf7b29ec27d15"
|
|
||||||
_em_ut_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
async def _refresh_em_ut() -> str:
|
|
||||||
"""
|
|
||||||
从东方财富前端 JS 中提取最新的 ut 令牌。
|
|
||||||
按优先级尝试:
|
|
||||||
1. bkzj/list.js(板块页专用)
|
|
||||||
2. common/emdataview.js(通用数据组件)
|
|
||||||
"""
|
|
||||||
urls = [
|
|
||||||
"https://data.eastmoney.com/newstatic/js/bkzj/list.js",
|
|
||||||
"https://data.eastmoney.com/newstatic/js/common/emdataview.js",
|
|
||||||
]
|
|
||||||
headers = {
|
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
||||||
"Referer": "https://data.eastmoney.com/bkzj/hy.html",
|
|
||||||
}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
for url in urls:
|
|
||||||
try:
|
|
||||||
resp = await client.get(url, headers=headers, timeout=10)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
continue
|
|
||||||
# 匹配 ut: 'xxxx' 或 ut:'xxxx' 或 ut: "xxxx"
|
|
||||||
m = re.search(r"""ut['"]?\s*:\s*['"]([a-f0-9]{32})['"]""", resp.text)
|
|
||||||
if m:
|
|
||||||
token = m.group(1)
|
|
||||||
print(f"[eastmoney] 已刷新 UT 令牌: {token[:8]}...")
|
|
||||||
return token
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[eastmoney] 获取 UT 失败({url}): {e}")
|
|
||||||
return _em_ut # 保底返回当前值
|
|
||||||
|
|
||||||
|
|
||||||
async def get_em_ut(force_refresh: bool = False) -> str:
|
|
||||||
"""获取当前 UT,必要时刷新"""
|
|
||||||
global _em_ut
|
|
||||||
if force_refresh:
|
|
||||||
async with _em_ut_lock:
|
|
||||||
_em_ut = await _refresh_em_ut()
|
|
||||||
return _em_ut
|
|
||||||
|
|
||||||
|
|
||||||
# ---- 板块数据(市场时间感知缓存)----
|
|
||||||
|
|
||||||
_CST = timezone(timedelta(hours=8)) # 北京时间
|
|
||||||
_TRADING_MORNING = (dtime(9, 30), dtime(11, 30))
|
|
||||||
_TRADING_AFTERNOON = (dtime(13, 0), dtime(15, 0))
|
|
||||||
|
|
||||||
|
|
||||||
def _cst_now() -> datetime:
|
|
||||||
return datetime.now(_CST)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_trading_time() -> bool:
|
|
||||||
"""判断当前是否为 A 股交易时段(周一至周五 9:30-11:30 / 13:00-15:00)"""
|
|
||||||
now = _cst_now()
|
|
||||||
if now.weekday() >= 5:
|
|
||||||
return False
|
|
||||||
t = now.time()
|
|
||||||
return (_TRADING_MORNING[0] <= t <= _TRADING_MORNING[1]
|
|
||||||
or _TRADING_AFTERNOON[0] <= t <= _TRADING_AFTERNOON[1])
|
|
||||||
|
|
||||||
|
|
||||||
def _sector_ttl_hours() -> int:
|
|
||||||
"""根据是否在交易时段返回缓存 TTL
|
|
||||||
- 交易时段: 2 分钟(数据持续变化)
|
|
||||||
- 非交易时段: 18 小时(覆盖到下一个交易日)
|
|
||||||
"""
|
|
||||||
return 0 if _is_trading_time() else 18
|
|
||||||
|
|
||||||
|
|
||||||
# curl_cffi 模拟 Chrome TLS 指纹
|
|
||||||
from curl_cffi.requests import AsyncSession
|
|
||||||
|
|
||||||
_sector_session: Optional[AsyncSession] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_sector_session() -> AsyncSession:
|
|
||||||
global _sector_session
|
|
||||||
if _sector_session is None:
|
|
||||||
_sector_session = AsyncSession(
|
|
||||||
impersonate="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_MEM_TTL = 60
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_sector_list(sector_type: str) -> list[dict]:
|
|
||||||
# 1. 内存缓存(仅交易时段有效,60s 避免重复请求)
|
|
||||||
now = time.time()
|
|
||||||
if sector_type in _sector_cache:
|
|
||||||
data, ts = _sector_cache[sector_type]
|
|
||||||
if now - ts < _SECTOR_MEM_TTL:
|
|
||||||
return data
|
|
||||||
|
|
||||||
cache_key = f"sector_list:{sector_type}"
|
|
||||||
|
|
||||||
# 2. 非交易时段:走磁盘持久缓存,不请求 API
|
|
||||||
if not _is_trading_time():
|
|
||||||
cached = get_cache(cache_key)
|
|
||||||
if cached is not None:
|
|
||||||
data = json.loads(cached)
|
|
||||||
_sector_cache[sector_type] = (data, now)
|
|
||||||
return data
|
|
||||||
|
|
||||||
# 3. 请求 API
|
|
||||||
data = await _fetch_push2(sector_type)
|
|
||||||
if data:
|
|
||||||
_sector_cache[sector_type] = (data, now)
|
|
||||||
ttl = _sector_ttl_hours()
|
|
||||||
# 交易时段 ttl=0 → 不写磁盘(内存缓存已够)
|
|
||||||
if ttl > 0:
|
|
||||||
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_hours=ttl)
|
|
||||||
return data
|
|
||||||
|
|
||||||
data = await _fetch_akshare(sector_type)
|
|
||||||
if data:
|
|
||||||
_sector_cache[sector_type] = (data, now)
|
|
||||||
# akshare 数据源更新不确定,不写入磁盘缓存
|
|
||||||
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"}
|
_F10_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"}
|
||||||
@@ -934,7 +711,6 @@ async def fetch_kline_history(code: str, days: int = 90) -> Optional[list[dict]]
|
|||||||
"turnoverRate": _f(10),
|
"turnoverRate": _f(10),
|
||||||
})
|
})
|
||||||
|
|
||||||
parsed.reverse()
|
|
||||||
return parsed
|
return parsed
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
@@ -963,16 +739,31 @@ async def fetch_fund_flow_daykline(code: str, days: int) -> Optional[list[dict]]
|
|||||||
"Referer": "https://quote.eastmoney.com/",
|
"Referer": "https://quote.eastmoney.com/",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from curl_cffi.requests import AsyncSession
|
||||||
|
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
async with httpx.AsyncClient() as client:
|
async with AsyncSession(impersonate="chrome120") as session:
|
||||||
try:
|
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 resp.status_code != 200:
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
result = resp.json()
|
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 not klines:
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
continue
|
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
|
return None
|
||||||
text = resp.content.decode("gbk", errors="replace")
|
text = resp.content.decode("gbk", errors="replace")
|
||||||
parts = parse_tencent_data(text)
|
parts = parse_tencent_data(text)
|
||||||
if not parts or len(parts) < 38:
|
if not parts or len(parts) < 47:
|
||||||
return None
|
return None
|
||||||
# 字段索引(1-based):1=名称, 3=当前价, 4=昨收, 5=今开, 6=成交量(手)
|
# 字段索引:1=名称, 3=当前价, 4=昨收, 5=今开, 6=成交量(手)
|
||||||
# 7=外盘, 8=内盘, 31=涨跌额, 32=涨跌幅%, 33=最高, 34=最低, 37=成交额(万)
|
# 7=外盘, 8=内盘, 31=涨跌额, 32=涨跌幅%, 33=最高, 34=最低, 37=成交额(万)
|
||||||
|
# 38=换手率%, 39=市盈率, 43=振幅%, 44=流通市值(亿), 45=总市值(亿), 46=市净率
|
||||||
name = parts[1] or ""
|
name = parts[1] or ""
|
||||||
current_price = float(parts[3]) if parts[3] else 0
|
current_price = float(parts[3]) if parts[3] else 0
|
||||||
yesterday_close = float(parts[4]) if parts[4] 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
|
change_pct = float(parts[32]) if parts[32] else 0
|
||||||
outer_disk = float(parts[7]) if parts[7] else 0
|
outer_disk = float(parts[7]) if parts[7] else 0
|
||||||
inner_disk = float(parts[8]) if parts[8] 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:
|
if not name or current_price == 0:
|
||||||
return None
|
return None
|
||||||
@@ -186,6 +193,11 @@ async def fetch_quote(code: str) -> Optional[dict]:
|
|||||||
"time": now.strftime("%H:%M:%S"),
|
"time": now.strftime("%H:%M:%S"),
|
||||||
"change": round(change, 2),
|
"change": round(change, 2),
|
||||||
"changePercent": round(change_percent, 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:
|
except Exception:
|
||||||
return None
|
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",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/node": "^22.20.0",
|
"@types/node": "^22.20.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@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))
|
version: 5.4.0(react-hook-form@7.81.0(react@19.2.7))
|
||||||
'@radix-ui/react-accordion':
|
'@radix-ui/react-accordion':
|
||||||
specifier: ^1.2.15
|
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':
|
'@radix-ui/react-alert-dialog':
|
||||||
specifier: ^1.1.18
|
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':
|
'@radix-ui/react-aspect-ratio':
|
||||||
specifier: ^1.1.11
|
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':
|
'@radix-ui/react-avatar':
|
||||||
specifier: ^1.2.1
|
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':
|
'@radix-ui/react-checkbox':
|
||||||
specifier: ^1.3.6
|
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':
|
'@radix-ui/react-collapsible':
|
||||||
specifier: ^1.1.15
|
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':
|
'@radix-ui/react-context-menu':
|
||||||
specifier: ^2.3.2
|
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':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.18
|
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':
|
'@radix-ui/react-dropdown-menu':
|
||||||
specifier: ^2.1.19
|
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':
|
'@radix-ui/react-hover-card':
|
||||||
specifier: ^1.1.18
|
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':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.11
|
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':
|
'@radix-ui/react-menubar':
|
||||||
specifier: ^1.1.19
|
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':
|
'@radix-ui/react-navigation-menu':
|
||||||
specifier: ^1.2.17
|
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':
|
'@radix-ui/react-popover':
|
||||||
specifier: ^1.1.18
|
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':
|
'@radix-ui/react-progress':
|
||||||
specifier: ^1.1.11
|
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':
|
'@radix-ui/react-radio-group':
|
||||||
specifier: ^1.4.2
|
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':
|
'@radix-ui/react-scroll-area':
|
||||||
specifier: ^1.2.13
|
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':
|
'@radix-ui/react-select':
|
||||||
specifier: ^2.3.2
|
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':
|
'@radix-ui/react-separator':
|
||||||
specifier: ^1.1.11
|
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':
|
'@radix-ui/react-slider':
|
||||||
specifier: ^1.4.2
|
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':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.3.0
|
specifier: ^1.3.0
|
||||||
version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||||
'@radix-ui/react-switch':
|
'@radix-ui/react-switch':
|
||||||
specifier: ^1.3.2
|
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':
|
'@radix-ui/react-tabs':
|
||||||
specifier: ^1.1.16
|
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':
|
'@radix-ui/react-toggle':
|
||||||
specifier: ^1.1.13
|
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':
|
'@radix-ui/react-toggle-group':
|
||||||
specifier: ^1.1.14
|
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':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: ^1.2.11
|
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':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.3.2
|
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))
|
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
|
version: 2.1.1
|
||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
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:
|
date-fns:
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
@@ -157,7 +160,7 @@ importers:
|
|||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
vaul:
|
vaul:
|
||||||
specifier: ^1.1.2
|
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:
|
vite-tsconfig-paths:
|
||||||
specifier: ^6.1.1
|
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))
|
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
|
specifier: ^3.25.76
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/d3-force':
|
||||||
|
specifier: ^3.0.10
|
||||||
|
version: 3.0.10
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.20.0
|
specifier: ^22.20.0
|
||||||
version: 22.20.0
|
version: 22.20.0
|
||||||
@@ -173,7 +179,7 @@ importers:
|
|||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
specifier: ^19.2.3
|
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':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^5.2.0
|
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))
|
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':
|
'@types/d3-ease@3.0.2':
|
||||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||||
|
|
||||||
|
'@types/d3-force@3.0.10':
|
||||||
|
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
||||||
|
|
||||||
'@types/d3-interpolate@3.0.4':
|
'@types/d3-interpolate@3.0.4':
|
||||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
@@ -1423,8 +1432,8 @@ packages:
|
|||||||
'@types/node@22.20.0':
|
'@types/node@22.20.0':
|
||||||
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
|
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
|
||||||
|
|
||||||
'@types/react-dom@19.2.3':
|
'@types/react-dom@19.2.4':
|
||||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/react': ^19.2.0
|
'@types/react': ^19.2.0
|
||||||
|
|
||||||
@@ -1495,10 +1504,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1:
|
||||||
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-ease@3.0.1:
|
d3-ease@3.0.1:
|
||||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
engines: {node: '>=12'}
|
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:
|
d3-format@3.1.2:
|
||||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1511,6 +1528,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-quadtree@3.0.1:
|
||||||
|
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
d3-scale@4.0.2:
|
d3-scale@4.0.2:
|
||||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2350,58 +2371,58 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/primitive@1.1.4': {}
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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.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-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-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-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-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)
|
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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.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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/react-context': 1.1.4(@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-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-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)
|
'@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)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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.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-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-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)
|
'@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)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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.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-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-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(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-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)
|
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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)':
|
'@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2461,18 +2482,18 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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.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-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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)':
|
'@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2480,18 +2501,18 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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-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.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.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)
|
'@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-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||||
aria-hidden: 1.2.6
|
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)
|
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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)':
|
'@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2508,33 +2529,33 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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)
|
'@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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.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-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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)':
|
'@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2542,33 +2563,33 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
'@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-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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)
|
||||||
'@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)
|
||||||
'@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)
|
||||||
'@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-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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)':
|
'@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2577,31 +2598,31 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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-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-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.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.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.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.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)
|
||||||
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(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-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||||
aria-hidden: 1.2.6
|
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)
|
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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-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.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.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)
|
||||||
'@radix-ui/react-use-controllable-state': 1.2.3(@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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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-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.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-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-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-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-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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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-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.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.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.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)
|
'@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-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||||
aria-hidden: 1.2.6
|
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)
|
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
'@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-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-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-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-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)
|
'@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)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
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)
|
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/react-use-layout-effect': 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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/react-context': 1.1.4(@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)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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.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.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)
|
||||||
'@radix-ui/react-use-controllable-state': 1.2.3(@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-previous': 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-use-size': 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)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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-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-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/number': 1.1.2
|
'@radix-ui/number': 1.1.2
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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.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-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-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/number': 1.1.2
|
'@radix-ui/number': 1.1.2
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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-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-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.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.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.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)
|
'@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-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-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-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-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
|
aria-hidden: 1.2.6
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(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)
|
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/number': 1.1.2
|
'@radix-ui/number': 1.1.2
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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-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-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)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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)':
|
'@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2848,12 +2869,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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)
|
'@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)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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.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.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)
|
||||||
'@radix-ui/react-use-controllable-state': 1.2.3(@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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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)
|
||||||
'@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)
|
||||||
'@radix-ui/react-use-controllable-state': 1.2.3(@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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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)
|
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.4
|
'@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-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-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-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-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.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.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.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)
|
'@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-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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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)':
|
'@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2978,14 +2999,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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:
|
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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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': {}
|
'@radix-ui/rect@1.1.2': {}
|
||||||
|
|
||||||
@@ -3251,6 +3272,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-ease@3.0.2': {}
|
'@types/d3-ease@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/d3-force@3.0.10': {}
|
||||||
|
|
||||||
'@types/d3-interpolate@3.0.4':
|
'@types/d3-interpolate@3.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-color': 3.1.3
|
'@types/d3-color': 3.1.3
|
||||||
@@ -3275,7 +3298,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
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:
|
dependencies:
|
||||||
'@types/react': 19.2.17
|
'@types/react': 19.2.17
|
||||||
|
|
||||||
@@ -3332,12 +3355,12 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
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:
|
dependencies:
|
||||||
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
'@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-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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -3356,8 +3379,16 @@ snapshots:
|
|||||||
|
|
||||||
d3-color@3.1.0: {}
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
d3-ease@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-format@3.1.2: {}
|
||||||
|
|
||||||
d3-interpolate@3.0.1:
|
d3-interpolate@3.0.1:
|
||||||
@@ -3366,6 +3397,8 @@ snapshots:
|
|||||||
|
|
||||||
d3-path@3.1.0: {}
|
d3-path@3.1.0: {}
|
||||||
|
|
||||||
|
d3-quadtree@3.0.1: {}
|
||||||
|
|
||||||
d3-scale@4.0.2:
|
d3-scale@4.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
d3-array: 3.2.4
|
d3-array: 3.2.4
|
||||||
@@ -3813,9 +3846,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
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:
|
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: 19.2.7
|
||||||
react-dom: 19.2.7(react@19.2.7)
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
transitivePeerDependencies:
|
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;
|
time: string;
|
||||||
change: number;
|
change: number;
|
||||||
changePercent: number;
|
changePercent: number;
|
||||||
|
turnoverRate: number; // 换手率%
|
||||||
|
pe: number; // 市盈率
|
||||||
|
pb: number; // 市净率
|
||||||
|
totalMarketCap: number; // 总市值
|
||||||
|
circulatingMarketCap: number; // 流通市值
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KLineData {
|
export interface KLineData {
|
||||||
@@ -92,7 +97,9 @@ export interface BoardInfo {
|
|||||||
* - 8/4/920开头:北交所(BJB)
|
* - 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")) {
|
if (code.startsWith("688")) {
|
||||||
return { board: "kcb", label: "科", className: "bg-red-500/10 text-red-500 border-red-500/30" };
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -1,14 +1,17 @@
|
|||||||
// 应用入口:样式在 ./styles.css(Tailwind v4 + design token),路由见 ./router.tsx
|
// 应用入口:样式在 ./styles.css(Tailwind v4 + design token),路由见 ./router.tsx
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { RouterProvider } from "@tanstack/react-router";
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
import { getRouter } from "./router";
|
import { getRouter } from "./router";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
const router = getRouter();
|
const { router, queryClient } = getRouter();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
</React.StrictMode>
|
</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.
|
// 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 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 IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
|
||||||
import { Route as StockCodeRouteImport } from './routes/stock.$code'
|
import { Route as StockCodeRouteImport } from './routes/stock.$code'
|
||||||
import { Route as ShareCodeRouteImport } from './routes/share.$code'
|
import { Route as ShareCodeRouteImport } from './routes/share.$code'
|
||||||
|
|
||||||
const SectorsRoute = SectorsRouteImport.update({
|
const ThemesRoute = ThemesRouteImport.update({
|
||||||
id: '/sectors',
|
id: '/themes',
|
||||||
path: '/sectors',
|
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,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
@@ -24,6 +37,11 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ThemeCodeRoute = ThemeCodeRouteImport.update({
|
||||||
|
id: '/theme/$code',
|
||||||
|
path: '/theme/$code',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const StockCodeRoute = StockCodeRouteImport.update({
|
const StockCodeRoute = StockCodeRouteImport.update({
|
||||||
id: '/stock/$code',
|
id: '/stock/$code',
|
||||||
path: '/stock/$code',
|
path: '/stock/$code',
|
||||||
@@ -37,45 +55,93 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
|
'/hot-map': typeof HotMapRoute
|
||||||
|
'/themes': typeof ThemesRoute
|
||||||
'/share/$code': typeof ShareCodeRoute
|
'/share/$code': typeof ShareCodeRoute
|
||||||
'/stock/$code': typeof StockCodeRoute
|
'/stock/$code': typeof StockCodeRoute
|
||||||
|
'/theme/$code': typeof ThemeCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
|
'/hot-map': typeof HotMapRoute
|
||||||
|
'/themes': typeof ThemesRoute
|
||||||
'/share/$code': typeof ShareCodeRoute
|
'/share/$code': typeof ShareCodeRoute
|
||||||
'/stock/$code': typeof StockCodeRoute
|
'/stock/$code': typeof StockCodeRoute
|
||||||
|
'/theme/$code': typeof ThemeCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
|
'/hot-map': typeof HotMapRoute
|
||||||
|
'/themes': typeof ThemesRoute
|
||||||
'/share/$code': typeof ShareCodeRoute
|
'/share/$code': typeof ShareCodeRoute
|
||||||
'/stock/$code': typeof StockCodeRoute
|
'/stock/$code': typeof StockCodeRoute
|
||||||
|
'/theme/$code': typeof ThemeCodeRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/sectors' | '/share/$code' | '/stock/$code'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/core-stocks'
|
||||||
|
| '/hot-map'
|
||||||
|
| '/themes'
|
||||||
|
| '/share/$code'
|
||||||
|
| '/stock/$code'
|
||||||
|
| '/theme/$code'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/sectors' | '/share/$code' | '/stock/$code'
|
to:
|
||||||
id: '__root__' | '/' | '/sectors' | '/share/$code' | '/stock/$code'
|
| '/'
|
||||||
|
| '/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
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
SectorsRoute: typeof SectorsRoute
|
CoreStocksRoute: typeof CoreStocksRoute
|
||||||
|
HotMapRoute: typeof HotMapRoute
|
||||||
|
ThemesRoute: typeof ThemesRoute
|
||||||
ShareCodeRoute: typeof ShareCodeRoute
|
ShareCodeRoute: typeof ShareCodeRoute
|
||||||
StockCodeRoute: typeof StockCodeRoute
|
StockCodeRoute: typeof StockCodeRoute
|
||||||
|
ThemeCodeRoute: typeof ThemeCodeRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/sectors': {
|
'/themes': {
|
||||||
id: '/sectors'
|
id: '/themes'
|
||||||
path: '/sectors'
|
path: '/themes'
|
||||||
fullPath: '/sectors'
|
fullPath: '/themes'
|
||||||
preLoaderRoute: typeof SectorsRouteImport
|
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
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/': {
|
'/': {
|
||||||
@@ -85,6 +151,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/theme/$code': {
|
||||||
|
id: '/theme/$code'
|
||||||
|
path: '/theme/$code'
|
||||||
|
fullPath: '/theme/$code'
|
||||||
|
preLoaderRoute: typeof ThemeCodeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/stock/$code': {
|
'/stock/$code': {
|
||||||
id: '/stock/$code'
|
id: '/stock/$code'
|
||||||
path: '/stock/$code'
|
path: '/stock/$code'
|
||||||
@@ -104,9 +177,12 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
SectorsRoute: SectorsRoute,
|
CoreStocksRoute: CoreStocksRoute,
|
||||||
|
HotMapRoute: HotMapRoute,
|
||||||
|
ThemesRoute: ThemesRoute,
|
||||||
ShareCodeRoute: ShareCodeRoute,
|
ShareCodeRoute: ShareCodeRoute,
|
||||||
StockCodeRoute: StockCodeRoute,
|
StockCodeRoute: StockCodeRoute,
|
||||||
|
ThemeCodeRoute: ThemeCodeRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
+1
-1
@@ -20,5 +20,5 @@ export const getRouter = () => {
|
|||||||
defaultPreloadStaleTime: 0,
|
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
+16
-8
@@ -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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
@@ -211,12 +211,20 @@ function Index() {
|
|||||||
A股走势追踪
|
A股走势追踪
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
|
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
|
||||||
<Link to="/sectors">
|
<div className="mt-3 flex items-center justify-center gap-2">
|
||||||
<Button variant="outline" size="sm" className="mt-3 gap-1.5 text-xs">
|
<Link to="/themes">
|
||||||
<TrendingUp className="h-3.5 w-3.5" />
|
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||||
板块资金流向
|
<Flame className="h-3.5 w-3.5" />
|
||||||
|
题材热点
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Card className="mb-6 md:mb-8 shadow-lg">
|
<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>
|
<p className="text-sm text-muted-foreground text-center py-4">暂无股票</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{stocks.slice(0, 10).map((stock) => (
|
{stocks.slice(0, 20).map((stock) => (
|
||||||
<StockRowItem
|
<StockRowItem
|
||||||
key={stock.id}
|
key={stock.id}
|
||||||
stock={stock}
|
stock={stock}
|
||||||
@@ -526,8 +534,8 @@ function CollectionCard({
|
|||||||
swipeResetKey={swipeResetKey}
|
swipeResetKey={swipeResetKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{stocks.length > 10 && (
|
{stocks.length > 20 && (
|
||||||
<p className="text-xs text-muted-foreground text-center pt-1">还有 {stocks.length - 10} 只股票...</p>
|
<p className="text-xs text-muted-foreground text-center pt-1">还有 {stocks.length - 20} 只股票...</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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;
|
addedPrice: number | null;
|
||||||
outerDisk: number; // 外盘(手)
|
outerDisk: number; // 外盘(手)
|
||||||
innerDisk: number; // 内盘(手)
|
innerDisk: number; // 内盘(手)
|
||||||
|
volume: number; // 成交量(股)
|
||||||
amount: number; // 成交额(元)
|
amount: number; // 成交额(元)
|
||||||
quoteDate: string; // 行情日期 YYYY-MM-DD
|
quoteDate: string; // 行情日期 YYYY-MM-DD
|
||||||
|
turnoverRate: number; // 换手率%
|
||||||
|
pe: number; // 市盈率
|
||||||
|
pb: number; // 市净率
|
||||||
|
totalMarketCap: number; // 总市值
|
||||||
|
circulatingMarketCap: number; // 流通市值
|
||||||
}
|
}
|
||||||
|
|
||||||
function StockDetail() {
|
function StockDetail() {
|
||||||
@@ -155,6 +161,12 @@ function StockDetail() {
|
|||||||
innerDisk: quote.innerDisk || 0,
|
innerDisk: quote.innerDisk || 0,
|
||||||
amount: (quote.amount || 0) * 10000,
|
amount: (quote.amount || 0) * 10000,
|
||||||
quoteDate: quote.date,
|
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,先渲染页面框架
|
// 基础信息已就绪,结束主loading,先渲染页面框架
|
||||||
@@ -267,6 +279,14 @@ function StockDetail() {
|
|||||||
negativeDays: negative,
|
negativeDays: negative,
|
||||||
};
|
};
|
||||||
}, [fundFlowSlice]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
@@ -331,12 +351,8 @@ function StockDetail() {
|
|||||||
ticks.push(added);
|
ticks.push(added);
|
||||||
}
|
}
|
||||||
ticks.push(last);
|
ticks.push(last);
|
||||||
return [...new Set(ticks)].sort((a, b) => {
|
// 保持chartData的原始顺序(已按日期升序排列),不做二次排序
|
||||||
// 按日期排序(MM/DD格式需按MM和DD比较)
|
return [...new Set(ticks)];
|
||||||
const [am, ad] = a.split('/').map(Number);
|
|
||||||
const [bm, bd] = b.split('/').map(Number);
|
|
||||||
return am - bm || ad - bd;
|
|
||||||
});
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// 东方财富市场标识:0=深圳(000/002/300), 1=上海(60), 6=科创板(688)
|
// 东方财富市场标识:0=深圳(000/002/300), 1=上海(60), 6=科创板(688)
|
||||||
@@ -447,6 +463,48 @@ function StockDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -653,6 +711,8 @@ function StockDetail() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Daily Data Table - Independent from fund flow */}
|
{/* Daily Data Table - Independent from fund flow */}
|
||||||
{chartData.length > 0 && (
|
{chartData.length > 0 && (
|
||||||
<Card className="mt-4 md:mt-6 shadow-lg">
|
<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