Compare commits

...
Author SHA1 Message Date
SakurasanandClaude 1550291d6b fix: 题材热点缓存改为开盘前失效,避免开盘后显示上个交易日数据
非交易时段(凌晨/早盘前/周末)访问题材接口时,旧逻辑把上个交易日
数据缓存 18 小时,导致开盘后缓存未过期仍读到旧数据。

- 非交易时段缓存 TTL 由固定 18h 改为"截止到下一次开盘时刻":
  早盘前→9:30 失效、午休→13:00 失效、收盘后/周末→下一交易日 9:30 失效
- 题材列表交易时段强制不读缓存,双保险实时拉取
- 热点穿透图/题材股票缓存同样收敛到开盘前失效

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 13:26:00 +08:00
SakurasanandClaude a1736c8a17 fix: 热点穿透顶栏手机端换行自适应
- 顶栏去掉固定 h-12,改为 flex-wrap + py-2,按钮组窄屏自动换行不再溢出
- 统计条 flex-wrap + whitespace-nowrap,题材/股票/穿透核心股窄屏自动换行
- 返回箭头/标题/刷新按钮 shrink-0 防压缩

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:03:40 +08:00
SakurasanandClaude 548ebee47f perf: 优化热点穿透加载速度 + Canvas 渲染 + 触控板/手机交互
后端加载优化(盘中原本每次访问都重新聚合,耗时 10-30 秒):
- 图聚合结果与题材股票子层盘中加 60 秒缓存
- 缓存过期时返回旧数据并后台幂等重建(stale-while-revalidate),打开即秒开
- 缓存基础支持秒级 TTL(set_cache 新增 ttl_seconds)
- 响应体瘦身:移除 edges(前端从 stocks[].themeCodes 重建)、
  themes 精简字段、stocks 按 limit 裁剪,JSON 从数 MB 降至数百 KB

前端 Canvas 渲染重构:
- d3-force 布局保留,SVG 渲染层替换为 Canvas 双缓冲(静止态离屏层 drawImage)
- tick 由每帧 setState 改为 rAF 合帧,拖拽/缩放期间零 React 重渲染,800 节点流畅

交互优化:
- 手机:新增 +/−/适应 缩放按钮、命中半径放大至 22px、tap/drag 6px 阈值区分、
  双指捏合缩放、触屏禁用 hover 避免与选中冲突
- Mac 触控板:双指滚动=平移、捏合(ctrlKey)=缩放、滚轮缩放保留、点空白取消选中

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 13:21:08 +08:00
SakurasanandClaude e28d1a2fd0 feat: 新增热点穿透网状关系图 + 移动端优化
- 新增 /hot-map 热点穿透页:d3-force 力导向网状图展示题材-股票 M:N 关系
  - 股票节点面积/颜色按覆盖题材数分级,穿透核心股高亮
  - 图/核心股列表双视图切换,列表覆盖题材>2 公司降序展示
  - 悬停信息卡、缩放平移、点击跳转股票/题材详情
- 数据采样改为涨幅+热度双榜合并(各 Top50 去重=82 题材),修复覆盖数低估
  (有研新材覆盖数 2→10,核心股 66→1124 家)
- 移动端适配:触屏 tap 选中底部浮层、标签缩放阈值防重叠、自动 fit、紧凑图例
- 主页/题材列表增加热点穿透入口

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 20:34:14 +08:00
SakurasanandClaude 45836f4a30 feat: 新增题材页面(列表 + 详情)
- 主页增加「题材热点」入口,跳转题材列表页
- 题材列表页:展示全部题材,支持按涨幅/强度/热度/成交额排序
- 题材详情页:简介、热点事件、相关新闻、板块涨跌统计、全部相关股票(含入选理由默认展开)
- 后端逆向封装东方财富题材接口 getThemeList/getDetail/getStockList,含交易时段感知缓存

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 17:22:04 +08:00
SakurasanandClaude b107a12798 fix: fund-flow 路由补充东方财富 kline 成交额
腾讯 fqkline 仅返回 6 个字段(date/open/close/high/low/volume),
不含成交额,导致 fund-flow 路由补充的 turnover 始终为 0。

在路由层额外调用 eastmoney.fetch_kline_history 获取成交额映射,
优先使用东方财富的成交额数据补充,腾讯 kline_map 作为兜底。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 21:14:09 +08:00
SakurasanandClaude 28ecc4d138 fix: fetch_fund_flow_daykline 增加 rc 校验和 data 空值保护
东方财富 daykline/get 接口返回 rc=100(data=null) 时无保护处理:
- 增加 result 类型检查、rc 状态校验、data 空值判断
- 避免 None.get('klines') 抛 AttributeError

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 18:49:48 +08:00
SakurasanandClaude 2a0960c251 修复两个问题:
1. 移除 eastmoney K线接口的 parsed.reverse(),统一为升序返回(与其他数据源一致),
   修复 K线图时间方向相反、每日行情明细展示远期数据的 bug

2. fetch_fund_flow_daykline 改用 curl_cffi 替代 httpx,匹配东方财富 push2his
   的 TLS 指纹检测,修复主力资金流向数据获取不到的 bug

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 18:19:30 +08:00
SakurasanandClaude b67d07e977 主页集合展示从 10 条改为 20 条
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 00:00:31 +08:00
SakurasanandClaude ea3da4e138 默认排序改为升序
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 23:49:02 +08:00
SakurasanandClaude bd1df35456 重构板块资金流向页面:三层数据分离(缓存→排序→展示),切换 Tab 清空缓存,排序改为资金/涨幅,新增 error/empty 状态处理
- 缓存数据、排序数据、展示数据三层分离架构
- 切换板块时 queryClient.removeQueries 清空全部缓存
- 排序维度改为 mainNetInflow(资金)和 mainNetInflowPercent(涨幅)
- 卡片展示主力净流入金额 + 占比
- 新增加载骨架屏、请求失败重试、空数据提示等状态
- 资金流向明细条(超大单/大单/中单/小单)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 23:46:00 +08:00
Sakurasan 22692be45e Revert "重构板块资金流向:行业/概念改用独立 path,后端排序,前端不缓存直接请求"
This reverts commit eea2ce86d0.
2026-07-13 23:24:26 +08:00
Sakurasan eea2ce86d0 重构板块资金流向:行业/概念改用独立 path,后端排序,前端不缓存直接请求 2026-07-13 23:22:09 +08:00
Sakurasan 94d84a368f 切换板块 tab 时强制 refetch,绕过前端/反代缓存确保数据正确 2026-07-13 22:44:34 +08:00
Sakurasan 3bfeb81c90 修复板块切换数据合并:禁止 API 响应缓存(反代按 path 忽略 query 导致两个 tab 数据相同) 2026-07-13 21:15:16 +08:00
Sakurasan ef795c46b2 修复板块切换数据合并:行业/概念使用独立 useQuery 隔离数据 2026-07-13 21:07:44 +08:00
Sakurasan 677482639d 板块资金流向默认按升序排序 2026-07-13 21:03:31 +08:00
Sakurasan 65013dad3e 重写板块资金流向前端:行业/概念 Tab 切换与资金流入/涨幅排序 2026-07-13 20:57:00 +08:00
Sakurasan 2db6bb8519 更新后端板块数据路由和服务(预提交) 2026-07-13 20:28:49 +08:00
Sakurasan fd00ce11d6 修复 React Query 未提供 QueryClientProvider 导致的运行时错误
- main.tsx 中增加 QueryClientProvider 包裹 RouterProvider
- router.tsx 的 getRouter() 改为返回 { router, queryClient } 对象
- 确保 useQuery 在组件树中能正常获取 QueryClient 上下文
2026-07-13 20:28:25 +08:00
Sakurasan 1da4f8f479 重写板块资金流向前端页面,使用 React Query 替代手动状态管理修复数据合并问题
- 使用 @tanstack/react-query 的 useQuery 替代 useState + useEffect + AbortController
- queryKey 按 tab 类型 (industry/concept) 隔离数据,根除快速切换导致的数据混用
- 增加空数据提示状态
- 保持原有 UI 结构和交互一致性
2026-07-13 20:24:25 +08:00
SakurasanandClaude 20b2afb0fc 修复总市值显示错误 & 板块切换数据合并问题
1. 后端: 修正腾讯API字段索引偏移 (total_market_cap [43]→[45],
   pb [45]→[46]),并处理市值单位转换(亿→元)
2. 前端: 板块切换时同步清空旧数据 + 显示加载态,避免新旧
   数据合并闪现

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 20:07:21 +08:00
SakurasanandClaude aaa618b15d K线图修复时间轴排序问题 & 行情指标合并到顶部卡片
- 修复K线图tickDates排序忽略年份导致跨年时期轴从右往左的问题
- 将行情指标(PE/PB/ROE/换手率/成交额/市值)合并到顶部股票信息卡片

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 15:10:43 +08:00
SakurasanandClaude 60eff4a955 feat: 股票详情页增加最新交易日PE/PB/ROE/换手率/市值等行情指标
- 后端tencent.py增加换手率/市盈率/市净率/总市值/流通市值字段提取
- 前端StockQuote接口新增对应字段类型定义
- 股票详情页增加"行情指标"卡片,展示PE/PB/ROE/换手率/总手/成交额/市值
- ROE取自财务分析数据的加权净资产收益率

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-08 19:27:23 +08:00
SakurasanandClaude Opus 4.7 86535f5de2 K线数据首选mootdx通达信TCP源,板块数据增加mootdx降级
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-08 02:09:57 +08:00
Sakurasan 015aeb989a 更新BK缓存策略 2026-07-08 00:33:15 +08:00
Sakurasan 1a50f7680a 并行获取板块数据 2026-07-07 20:28:24 +08:00
Sakurasan e29e3d32fb 板块缓存 2026-07-07 17:54:50 +08:00
Sakurasan c6cef8bb4c update: 板块缓存 2026-07-07 17:51:28 +08:00
23 changed files with 3531 additions and 388 deletions
+2 -1
View File
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
from dotenv import load_dotenv
from database import init_db
from routes import stock, collections, shares, sectors
from routes import stock, collections, shares, sectors, themes
load_dotenv()
@@ -31,6 +31,7 @@ app.include_router(stock.router, prefix="/api/stock")
app.include_router(collections.router, prefix="/api/collections")
app.include_router(shares.router, prefix="/api/share")
app.include_router(sectors.router, prefix="/api/sectors")
app.include_router(themes.router, prefix="/api/themes")
# 生产模式:后端同时托管前端静态文件
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
+2 -1
View File
@@ -2,4 +2,5 @@ fastapi==0.115.0
uvicorn==0.30.0
httpx==0.27.0
python-dotenv==1.0.1
akshare==1.18.64
akshare==1.18.64
mootdx
+24 -2
View File
@@ -1,10 +1,15 @@
"""板块数据路由:行业板块、概念板块"""
from fastapi import APIRouter, Query, HTTPException
from services import eastmoney
from fastapi.responses import JSONResponse
from services import eastmoney, mootdx
router = APIRouter()
# 行业/概念通过 query 参数区分,但上游反代/CDN 可能按 path 缓存而忽略 query,
# 导致两个 tab 返回相同数据。显式禁止缓存,保证按 query 区分。
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
@router.get("", summary="板块列表")
async def sector_list(
@@ -14,4 +19,21 @@ async def sector_list(
raise HTTPException(status_code=400, detail="板块类型错误,仅支持 industry/concept")
data = await eastmoney.fetch_sector_list(type)
return {"data": data, "count": len(data), "type": type}
if data:
return JSONResponse(
{"data": data, "count": len(data), "type": type},
headers=_NO_CACHE_HEADERS,
)
# 降级:通达信 mootdx(不含实时资金流数据)
md_data = await mootdx.fetch_sector_list(type)
if md_data:
return JSONResponse(
{"data": md_data, "count": len(md_data), "type": type, "source": "mootdx"},
headers=_NO_CACHE_HEADERS,
)
return JSONResponse(
{"data": [], "count": 0, "type": type},
headers=_NO_CACHE_HEADERS,
)
+37 -6
View File
@@ -3,7 +3,7 @@
from fastapi import APIRouter, Query, HTTPException
import re
from services import tencent, sina, eastmoney
from services import tencent, sina, eastmoney, mootdx
from models import StockSearchResult, StockQuote, KLineData, FundFlowData, FundFlowSummary, CompanyProfile, FinancialReportItem, FinancialDataResponse
router = APIRouter()
@@ -54,21 +54,28 @@ async def stock_history(
if not re.match(r"^\d{6}$", code):
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
# 主数据源:东方财富 push2his(含成交额/涨跌幅/振幅/换手率,可能被限流
# 主数据源:通达信 mootdx(TCP直连,不被限流,稳定可靠
md_klines = await mootdx.fetch_kline_history(code, days)
if md_klines and len(md_klines) >= 2:
return {"data": md_klines, "count": len(md_klines), "source": "mootdx"}
# 降级1:东方财富 push2his(含成交额/涨跌幅/振幅/换手率)
em_klines = await eastmoney.fetch_kline_history(code, days)
if em_klines and len(em_klines) >= 2:
return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"}
# 降级1:腾讯(含涨跌幅)
# 降级2:腾讯(含涨跌幅)
tencent_klines = await tencent.fetch_history(code, days)
if tencent_klines and len(tencent_klines) >= 2:
return {"data": tencent_klines, "count": len(tencent_klines), "source": "tencent"}
# 降级2:新浪
# 降级3:新浪
sina_klines = await sina.fetch_history(code, days)
if sina_klines:
return {"data": sina_klines, "count": len(sina_klines), "source": "sina"}
if md_klines:
return {"data": md_klines, "count": len(md_klines), "source": "mootdx"}
if em_klines:
return {"data": em_klines, "count": len(em_klines), "source": "eastmoney"}
if tencent_klines:
@@ -194,12 +201,20 @@ async def stock_fund_flow(
if not data:
raise HTTPException(status_code=404, detail="未获取到资金流向数据")
# 从腾讯 K 线补充成交额/收盘价/涨跌幅
# 从腾讯 K 线补充收盘价/涨跌幅
kline_map = await tencent.fetch_kline_map(code, days)
# 从东方财富 K 线补充成交额(腾讯 fqkline 不含成交额字段)
em_klines = await eastmoney.fetch_kline_history(code, days)
em_kline_map = {}
if em_klines:
for k in em_klines:
if k.get("turnover"):
em_kline_map[k["date"]] = k["turnover"]
for d in data:
ki = kline_map.get(d["date"], {})
if d.get("turnover", 0) == 0:
d["turnover"] = ki.get("turnover", 0)
# 优先从东方财富 kline 拿成交额,其次腾讯
d["turnover"] = em_kline_map.get(d["date"], ki.get("turnover", 0))
if d.get("closePrice", 0) == 0:
d["closePrice"] = ki.get("close", 0)
if d.get("changePercent", 0) == 0:
@@ -238,3 +253,19 @@ async def stock_fund_flow(
"negativeDays": negative,
},
}
@router.get("/mx-tool", summary="MX 通用金融数据查询")
async def mx_tool(
query: str = Query(..., description="自然语言问句,如:「格力电器2024年净利润」「沪深300最新收盘价」「市盈率最低的50只股票」"),
):
"""通过 MX 妙想 API 查询任意金融数据(A股/港股/美股/基金/债券/指数/板块/宏观/新闻/公告/选股等)
单次查询最多支持 20 只证券。返回原始结构化表格(sheetName + columns + items)。
"""
if not query or not query.strip():
raise HTTPException(status_code=400, detail="查询内容不能为空")
data = await eastmoney.fetch_mx_tool(query.strip())
if not data:
raise HTTPException(status_code=404, detail="MX 查询无结果或所有 API Key 已耗尽")
return {"data": data}
+66
View File
@@ -0,0 +1,66 @@
"""题材数据路由:题材列表、题材详情、题材相关股票"""
from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import JSONResponse
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("/{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,
)
+3 -3
View File
@@ -23,9 +23,9 @@ def get_cache(key: str) -> Optional[str]:
conn.close()
def set_cache(key: str, value: str, ttl_hours: int = 6):
"""写入缓存,过期时间 = now + ttl_hours"""
expires_at = (datetime.now() + timedelta(hours=ttl_hours)).isoformat()
def set_cache(key: str, value: str, ttl_hours: int = 6, ttl_seconds: int = 0):
"""写入缓存,过期时间 = now + ttl_hours + ttl_seconds(支持秒级短 TTL"""
expires_at = (datetime.now() + timedelta(hours=ttl_hours, seconds=ttl_seconds)).isoformat()
conn = get_connection()
try:
conn.execute(
+194 -31
View File
@@ -6,11 +6,13 @@ import httpx
import json
import os
import re
from datetime import datetime
from datetime import datetime, time as dtime, timedelta, timezone
from typing import Optional, List
from services.cache import get_cache, set_cache
from services.cache import get_cache, set_cache
# ---- API Key 轮询(MX 备选源用)----
@@ -204,6 +206,75 @@ async def fetch_mx_api(name: str, days: int) -> Optional[List[dict]]:
return None
async def fetch_mx_tool(tool_query: str) -> Optional[dict]:
"""通用 MX 妙想 API 查询(自然语言 → 结构化数据,缓存 6 小时)
支持东方财富数据库的全品类查询,包括但不限于:
- A 股/港股/美股行情、财务、估值、股本、事件
- 基金净值、收益、持仓、排名
- 债券基本信息、久期凸性、信用评级
- 指数与板块行情、技术指标
- 宏观经济/行业经济/大宗商品数据
- 新闻研报、公告搜索
- 多条件选股/选基/选债
Args:
tool_query: 自然语言问句,如 "格力电器2024年营业收入和净利润"
"沪深300最新收盘价" "市盈率最低的50只股票"
Returns:
MX API 原始 JSONstatus=0 时成功,data 中含 sheetName/columns/items
失败返回 None
"""
cache_key = f"mx_tool:{tool_query}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
if not _ensure_keys():
return None
url = "https://mkapi2.dfcfs.com/finskillshub/api/claw/query"
payload = {"toolQuery": tool_query}
for attempt in range(len(_api_keys)):
key = _get_key()
async with httpx.AsyncClient() as client:
try:
resp = await client.post(
url,
json=payload,
headers={"Content-Type": "application/json", "apikey": key},
timeout=15,
)
if resp.status_code != 200:
_rotate_key()
continue
result = resp.json()
except Exception as e:
print(f"[eastmoney] MX tool error: {e}")
_rotate_key()
continue
status = result.get("status", -1)
if status == 113:
print(f"[eastmoney] key 已达每日上限,切换到下一个")
_rotate_key()
continue
if status == 114:
_rotate_key()
continue
if status != 0:
_rotate_key()
continue
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_hours=6)
return result
print(f"[eastmoney] 所有 MX API key 均已耗尽")
return None
def get_eastmoney_market(code: str) -> str:
"""获取东方财富格式的市场标识"""
if code.startswith("688"):
@@ -277,6 +348,35 @@ async def get_em_ut(force_refresh: bool = False) -> str:
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
@@ -287,39 +387,63 @@ def _get_sector_session() -> AsyncSession:
global _sector_session
if _sector_session is None:
_sector_session = AsyncSession(
impersonate="chrome120",
impersonate="chrome131",
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",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Referer": "https://data.eastmoney.com/bkzj/hy.html",
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9",
},
timeout=10,
timeout=5,
)
return _sector_session
# 内存缓存
# 内存缓存(加速交易时段频繁请求)
_sector_cache: dict[str, tuple[list[dict], float]] = {}
_SECTOR_CACHE_TTL = 60
_SECTOR_MEM_TTL = 60
async def fetch_sector_list(sector_type: str) -> list[dict]:
# 1. 内存缓存
now = time.time()
if sector_type in _sector_cache:
data, ts = _sector_cache[sector_type]
if now - ts < _SECTOR_CACHE_TTL:
if now - ts < _SECTOR_MEM_TTL:
return data
data = await _fetch_push2(sector_type)
if data:
_sector_cache[sector_type] = (data, now)
return data
cache_key = f"sector_list:{sector_type}"
data = await _fetch_akshare(sector_type)
if data:
_sector_cache[sector_type] = (data, now)
return data
# 2. 非交易时段:走磁盘持久缓存
if not _is_trading_time():
cached = get_cache(cache_key)
if cached is not None:
data = json.loads(cached)
_sector_cache[sector_type] = (data, now)
return data
# 3. 并发请求 push2 和 akshare,优先使用 push2
push2_task = asyncio.create_task(_fetch_push2(sector_type))
akshare_task = asyncio.create_task(_fetch_akshare(sector_type))
push2_data = await push2_task
if push2_data:
akshare_task.cancel()
try:
await akshare_task
except asyncio.CancelledError:
pass
_sector_cache[sector_type] = (push2_data, now)
ttl = _sector_ttl_hours()
if ttl > 0:
set_cache(cache_key, json.dumps(push2_data, ensure_ascii=False), ttl_hours=ttl)
return push2_data
# push2 失败,用 akshare(不写磁盘缓存)
akshare_data = await akshare_task
if akshare_data:
_sector_cache[sector_type] = (akshare_data, now)
return akshare_data
async def _fetch_push2(sector_type: str) -> list[dict]:
@@ -330,14 +454,14 @@ async def _fetch_push2(sector_type: str) -> list[dict]:
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):
url = (
f"https://push2.eastmoney.com/api/qt/clist/get"
f"?fs={fs}&fields={SECTOR_FIELDS}"
f"&fid=f62&po=1&pz=500&pn=1&np=1&fltt=2"
f"&invt=2&ut={ut}"
)
try:
resp = await session.get(url)
if resp.status_code != 200:
@@ -377,6 +501,11 @@ async def _fetch_push2(sector_type: str) -> list[dict]:
if "disconnect" in err.lower() or "refused" in err.lower() or attempt == 1:
await get_em_ut(force_refresh=True)
ut = _em_ut
# 先尝试更新现有会话的 headers
try:
session.headers.update({"Referer": "https://data.eastmoney.com/bkzj/hy.html"})
except Exception:
pass
# 重建会话(TLS 指纹可能会被缓存)
global _sector_session
_sector_session = None
@@ -390,10 +519,12 @@ import akshare as ak
async def _fetch_akshare(sector_type: str) -> list[dict]:
"""akshare 降级方案(同花顺数据源)"""
"""akshare 降级方案(东方财富数据源)"""
loop = asyncio.get_event_loop()
code_map_key = f"board_codes:{sector_type}"
def _get():
def _build_code_map():
"""获取板块代码映射(HTTP 较慢,结果单独缓存 24h)"""
code_map = {}
try:
if sector_type == "industry":
@@ -414,16 +545,34 @@ async def _fetch_akshare(sector_type: str) -> list[dict]:
code_map[str(r.get("name", ""))] = str(r.get("code", ""))
except Exception:
pass
return code_map
def _get_fund_flow():
if sector_type == "industry":
df = ak.stock_fund_flow_industry()
return ak.stock_fund_flow_industry()
else:
df = ak.stock_fund_flow_concept()
return ak.stock_fund_flow_concept()
return code_map, df
# 1. 尝试从缓存读取 code_map
code_map = {}
cached_map = get_cache(code_map_key)
if cached_map is not None:
code_map = json.loads(cached_map)
try:
code_map, df = await loop.run_in_executor(None, _get)
if code_map:
# 已有缓存,只需获取资金流
df = await loop.run_in_executor(None, _get_fund_flow)
else:
# 首次:code_map + 资金流并发获取
map_data, df = await asyncio.gather(
loop.run_in_executor(None, _build_code_map),
loop.run_in_executor(None, _get_fund_flow),
)
if map_data:
code_map = map_data
set_cache(code_map_key, json.dumps(code_map, ensure_ascii=False), ttl_hours=24)
if df is None or df.empty:
return []
df = df.sort_values("净额", ascending=False)
@@ -886,7 +1035,6 @@ async def fetch_kline_history(code: str, days: int = 90) -> Optional[list[dict]]
"turnoverRate": _f(10),
})
parsed.reverse()
return parsed
except Exception as e:
if attempt == 0:
@@ -915,16 +1063,31 @@ async def fetch_fund_flow_daykline(code: str, days: int) -> Optional[list[dict]]
"Referer": "https://quote.eastmoney.com/",
}
from curl_cffi.requests import AsyncSession
for attempt in range(2):
async with httpx.AsyncClient() as client:
async with AsyncSession(impersonate="chrome120") as session:
try:
resp = await client.get(url, headers=headers, timeout=10)
resp = await session.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
if attempt == 0:
continue
return None
result = resp.json()
klines = result.get("data", {}).get("klines", [])
if not isinstance(result, dict):
if attempt == 0:
continue
return None
if result.get("rc", -1) != 0:
if attempt == 0:
continue
return None
data = result.get("data")
if not data or not isinstance(data, dict):
if attempt == 0:
continue
return None
klines = data.get("klines", [])
if not klines:
if attempt == 0:
continue
+128
View File
@@ -0,0 +1,128 @@
"""通达信数据源(mootdx TCP直连通达信服务器)
通过 TCP 协议直连通达信行情服务器,不走 HTTP,不会被限流。
主要用途:
- K线数据:主数据源(稳定可靠)
- 板块数据:东方财富 push2 的降级方案
"""
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
# ---- 板块数据(东方财富降级方案)----
def _sync_fetch_sectors(sector_type: str) -> Optional[list]:
from mootdx.consts import MARKET_SH, MARKET_SZ
client = _create_client()
# block() 返回 DataFrame,列:code, name 等
# 按板块类型过滤
block_df = client.block()
if block_df is None or block_df.empty:
return None
items = []
for _, row in block_df.iterrows():
name = str(row.get("name", "") or row.get("blockname", ""))
code = str(row.get("code", "") or row.get("blockcode", ""))
if not code or not name:
continue
items.append(
{
"code": code,
"name": name,
"level": None,
"changePercent": None,
"changeAmount": None,
"mainNetInflow": 0,
"mainNetInflowPercent": None,
"superLargeInflow": None,
"superLargeInflowPercent": None,
"largeInflow": None,
"largeInflowPercent": None,
"mediumInflow": None,
"mediumInflowPercent": None,
"smallInflow": None,
"smallInflowPercent": None,
"turnover": 0,
}
)
return items if items else None
async def fetch_sector_list(sector_type: str) -> Optional[List[dict]]:
"""获取板块列表(东方财富的降级方案,仅含代码和名称)"""
try:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _sync_fetch_sectors, sector_type)
except ImportError:
return None
except Exception as e:
print(f"[mootdx] fetch_sectors error: {e}")
return None
+14 -2
View File
@@ -142,10 +142,11 @@ async def fetch_quote(code: str) -> Optional[dict]:
return None
text = resp.content.decode("gbk", errors="replace")
parts = parse_tencent_data(text)
if not parts or len(parts) < 38:
if not parts or len(parts) < 47:
return None
# 字段索引(1-based)1=名称, 3=当前价, 4=昨收, 5=今开, 6=成交量(手)
# 字段索引:1=名称, 3=当前价, 4=昨收, 5=今开, 6=成交量(手)
# 7=外盘, 8=内盘, 31=涨跌额, 32=涨跌幅%, 33=最高, 34=最低, 37=成交额(万)
# 38=换手率%, 39=市盈率, 43=振幅%, 44=流通市值(亿), 45=总市值(亿), 46=市净率
name = parts[1] or ""
current_price = float(parts[3]) if parts[3] else 0
yesterday_close = float(parts[4]) if parts[4] else 0
@@ -158,6 +159,12 @@ async def fetch_quote(code: str) -> Optional[dict]:
change_pct = float(parts[32]) if parts[32] else 0
outer_disk = float(parts[7]) if parts[7] else 0
inner_disk = float(parts[8]) if parts[8] else 0
turnover_rate = float(parts[38]) if parts[38] else 0 # 换手率%
pe = float(parts[39]) if parts[39] else 0 # 市盈率
# 腾讯API返回的市值单位是亿, formatMoney期望元, 需×1e8
total_market_cap = float(parts[45]) * 1e8 if parts[45] else 0 # 总市值(亿→元)
circulating_market_cap = float(parts[44]) * 1e8 if parts[44] else 0 # 流通市值(亿→元)
pb = float(parts[46]) if parts[46] else 0 # 市净率
if not name or current_price == 0:
return None
@@ -186,6 +193,11 @@ async def fetch_quote(code: str) -> Optional[dict]:
"time": now.strftime("%H:%M:%S"),
"change": round(change, 2),
"changePercent": round(change_percent, 2),
"turnoverRate": round(turnover_rate, 2),
"pe": round(pe, 2) if pe else 0,
"pb": round(pb, 2) if pb else 0,
"totalMarketCap": round(total_market_cap, 2),
"circulatingMarketCap": round(circulating_market_cap, 2),
}
except Exception:
return None
+432
View File
@@ -0,0 +1,432 @@
"""东方财富题材数据服务:题材列表、题材详情、题材相关股票
逆向自 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"
_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Origin": "https://emrnweb.eastmoney.com",
"Referer": "https://emrnweb.eastmoney.com/",
"Accept": "application/json",
"Accept-Language": "zh-CN,zh;q=0.9",
}
# ---- 交易时段感知缓存 ----
_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
def _list_ttl_seconds() -> int:
"""题材列表缓存秒数:交易时段 0(不缓存、实时拉取);非交易时段缓存到下次开盘前失效"""
return 0 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": "iOS",
"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}"
# 交易时段强制实时:跳过缓存读取,避免命中非交易时段写入的上个交易日旧数据
if not _is_trading_time():
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_atepoch 秒)"""
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"]}
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
缓存命中且未过期 → 直接返回;已过期 → 返回旧数据并后台异步重建(秒开);
无缓存 → 同步构建(并发下加锁去重)。返回前按 limit 裁剪 stocks。
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:
# 有缓存:新鲜直接返回;过期返回旧数据并后台刷新
if not (expires_at and expires_at > time.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:
# 等待锁期间已被其他请求写入
if not (expires_at and expires_at > time.time()):
_spawn_rebuild(cache_key, sort_field, top_n)
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)
+2
View File
@@ -44,6 +44,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0",
"date-fns": "^4.4.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^11.18.2",
@@ -64,6 +65,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@types/d3-force": "^3.0.10",
"@types/node": "^22.20.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
+231 -198
View File
@@ -13,82 +13,82 @@ importers:
version: 5.4.0(react-hook-form@7.81.0(react@19.2.7))
'@radix-ui/react-accordion':
specifier: ^1.2.15
version: 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.2.15(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-alert-dialog':
specifier: ^1.1.18
version: 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-aspect-ratio':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-avatar':
specifier: ^1.2.1
version: 1.2.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.2.1(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-checkbox':
specifier: ^1.3.6
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.3.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-collapsible':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-context-menu':
specifier: ^2.3.2
version: 2.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 2.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dialog':
specifier: ^1.1.18
version: 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.19
version: 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-hover-card':
specifier: ^1.1.18
version: 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-label':
specifier: ^2.1.11
version: 2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 2.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-menubar':
specifier: ^1.1.19
version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-navigation-menu':
specifier: ^1.2.17
version: 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.2.17(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-popover':
specifier: ^1.1.18
version: 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-progress':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-radio-group':
specifier: ^1.4.2
version: 1.4.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.4.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-scroll-area':
specifier: ^1.2.13
version: 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.2.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-select':
specifier: ^2.3.2
version: 2.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 2.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-separator':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slider':
specifier: ^1.4.2
version: 1.4.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.4.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot':
specifier: ^1.3.0
version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-switch':
specifier: ^1.3.2
version: 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-tabs':
specifier: ^1.1.16
version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.16(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-toggle':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-toggle-group':
specifier: ^1.1.14
version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-tooltip':
specifier: ^1.2.11
version: 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.2.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tailwindcss/vite':
specifier: ^4.3.2
version: 4.3.2(vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5))
@@ -109,7 +109,10 @@ importers:
version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.1(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
d3-force:
specifier: ^3.0.0
version: 3.0.0
date-fns:
specifier: ^4.4.0
version: 4.4.0
@@ -157,7 +160,7 @@ importers:
version: 1.4.0
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 1.1.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
vite-tsconfig-paths:
specifier: ^6.1.1
version: 6.1.1(typescript@5.9.3)(vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5))
@@ -165,6 +168,9 @@ importers:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
'@types/d3-force':
specifier: ^3.0.10
version: 3.0.10
'@types/node':
specifier: ^22.20.0
version: 22.20.0
@@ -173,7 +179,7 @@ importers:
version: 19.2.17
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.17)
version: 19.2.4(@types/react@19.2.17)
'@vitejs/plugin-react':
specifier: ^5.2.0
version: 5.2.0(vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.5))
@@ -1399,6 +1405,9 @@ packages:
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
@@ -1423,8 +1432,8 @@ packages:
'@types/node@22.20.0':
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
'@types/react-dom@19.2.4':
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
peerDependencies:
'@types/react': ^19.2.0
@@ -1495,10 +1504,18 @@ packages:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-force@3.0.0:
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
@@ -1511,6 +1528,10 @@ packages:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-quadtree@3.0.1:
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
@@ -2350,58 +2371,58 @@ snapshots:
'@radix-ui/primitive@1.1.4': {}
'@radix-ui/react-accordion@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-accordion@1.2.15(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-collapsible': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-collapsible': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-alert-dialog@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-alert-dialog@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-aspect-ratio@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-aspect-ratio@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-avatar@1.2.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-avatar@1.2.1(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
@@ -2409,15 +2430,15 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-checkbox@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-checkbox@1.3.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
@@ -2425,35 +2446,35 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-collapsible@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-collapsible@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-collection@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-collection@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)':
dependencies:
@@ -2461,18 +2482,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-context-menu@2.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-context-menu@2.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)':
dependencies:
@@ -2480,18 +2501,18 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-dialog@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-dialog@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
aria-hidden: 1.2.6
@@ -2500,7 +2521,7 @@ snapshots:
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
@@ -2508,33 +2529,33 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-dismissable-layer@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-dismissable-layer@1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-dropdown-menu@2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-dropdown-menu@2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)':
dependencies:
@@ -2542,33 +2563,33 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-focus-scope@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-focus-scope@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-hover-card@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-hover-card@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
@@ -2577,31 +2598,31 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-label@2.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-label@2.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-menu@2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-menu@2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
aria-hidden: 1.2.6
@@ -2610,61 +2631,61 @@ snapshots:
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-menubar@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-menubar@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-navigation-menu@1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-navigation-menu@1.2.17(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-popover@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-popover@1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
aria-hidden: 1.2.6
@@ -2673,15 +2694,15 @@ snapshots:
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-popper@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-popper@1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
@@ -2691,55 +2712,55 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-progress@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-progress@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-radio-group@1.4.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-radio-group@1.4.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
@@ -2747,90 +2768,90 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-roving-focus@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-roving-focus@1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-scroll-area@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-scroll-area@1.2.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/number': 1.1.2
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-select@2.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-select@2.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/number': 1.1.2
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
aria-hidden: 1.2.6
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-separator@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-separator@1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-slider@1.4.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-slider@1.4.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/number': 1.1.2
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
@@ -2839,7 +2860,7 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)':
dependencies:
@@ -2848,12 +2869,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-switch@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-switch@1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
@@ -2861,69 +2882,69 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-tabs@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-tabs@1.1.16(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-toggle-group@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-toggle-group@1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-toggle': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-toggle': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-toggle@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-toggle@1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-tooltip@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-tooltip@1.2.11(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
@@ -2978,14 +2999,14 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-visually-hidden@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
'@radix-ui/react-visually-hidden@1.2.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@types/react-dom': 19.2.4(@types/react@19.2.17)
'@radix-ui/rect@1.1.2': {}
@@ -3251,6 +3272,8 @@ snapshots:
'@types/d3-ease@3.0.2': {}
'@types/d3-force@3.0.10': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
@@ -3275,7 +3298,7 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/react-dom@19.2.3(@types/react@19.2.17)':
'@types/react-dom@19.2.4(@types/react@19.2.17)':
dependencies:
'@types/react': 19.2.17
@@ -3332,12 +3355,12 @@ snapshots:
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
cmdk@1.1.1(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
transitivePeerDependencies:
@@ -3356,8 +3379,16 @@ snapshots:
d3-color@3.1.0: {}
d3-dispatch@3.0.1: {}
d3-ease@3.0.1: {}
d3-force@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-quadtree: 3.0.1
d3-timer: 3.0.1
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
@@ -3366,6 +3397,8 @@ snapshots:
d3-path@3.1.0: {}
d3-quadtree@3.0.1: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
@@ -3813,9 +3846,9 @@ snapshots:
dependencies:
react: 19.2.7
vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
vaul@1.1.2(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dialog': 1.1.18(@types/react-dom@19.2.4(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
transitivePeerDependencies:
+7 -2
View File
@@ -18,6 +18,11 @@ export interface StockQuote {
time: string;
change: number;
changePercent: number;
turnoverRate: number; // 换手率%
pe: number; // 市盈率
pb: number; // 市净率
totalMarketCap: number; // 总市值
circulatingMarketCap: number; // 流通市值
}
export interface KLineData {
@@ -458,12 +463,12 @@ export interface SectorResponse {
*
* @param type industry=, concept=
*/
export async function fetchSectors(type: SectorType): Promise<SectorItem[]> {
export async function fetchSectors(type: SectorType, signal?: AbortSignal): Promise<SectorItem[]> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/sectors?type=${type}`;
try {
const resp = await fetch(url, { method: "GET" });
const resp = await fetch(url, { method: "GET", signal, cache: "no-store" });
if (!resp.ok) return [];
const result: SectorResponse = await resp.json();
return result.data || [];
+229
View File
@@ -0,0 +1,229 @@
// 题材数据获取工具:通过 Python 后端代理调用东方财富题材接口
import { getApiBaseUrl } from "@/lib/api-client";
/* ── 题材列表 ── */
export interface ThemeItem {
themeCode: string;
themeName: string;
securityName: string; // 领涨股名称
securityCode: string; // 领涨股代码
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 GraphTheme {
themeCode: string;
themeName: string;
stockCount: number; // 题材内股票数
}
export interface GraphStock {
securityCode: string;
securityName: string;
coverCount: number; // 覆盖题材数(穿透强度)
f3: number | null; // 涨幅
f2: number | null; // 现价
f62: number | null; // 主力净流入
f100: string; // 行业
themeCodes: string[]; // 所属题材代码
}
export interface GraphStats {
themeCount: number;
stockCount: number;
coreCount: number; // 覆盖≥2 的核心股数
maxCover: number; // 最大覆盖题材数
}
export interface ThemeGraph {
themes: GraphTheme[];
stocks: GraphStock[];
stats: GraphStats;
}
/**
* edges
* d3-force 使 source/target id"t:题材code" / "s:股票code"
*/
export function buildEdgesFromStocks(stocks: GraphStock[]): { source: string; target: string }[] {
const edges: { source: string; target: string }[] = [];
for (const s of stocks) {
const target = `s:${s.securityCode}`;
for (const tc of s.themeCodes) edges.push({ source: `t:${tc}`, target });
}
return edges;
}
/**
* 穿- M:N
* @param sortField 1= 4=
* @param top
* @param limit 穿 N
*/
export async function fetchThemeGraph(
sortField: 1 | 4 = 1,
top: number = 30,
limit: number = 1000,
): Promise<ThemeGraph | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes/graph?sort_field=${sortField}&top=${top}&limit=${limit}`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
return await resp.json();
} catch (err) {
console.error("[theme-api] 获取热点穿透图数据失败:", err);
return null;
}
}
+5 -2
View File
@@ -1,14 +1,17 @@
// 应用入口:样式在 ./styles.cssTailwind v4 + design token),路由见 ./router.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { getRouter } from "./router";
import "./styles.css";
const router = getRouter();
const { router, queryClient } = getRouter();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>
);
+79 -3
View File
@@ -9,21 +9,39 @@
// 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 ThemesRouteImport } from './routes/themes'
import { Route as SectorsRouteImport } from './routes/sectors'
import { Route as HotMapRouteImport } from './routes/hot-map'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
import { Route as StockCodeRouteImport } from './routes/stock.$code'
import { Route as ShareCodeRouteImport } from './routes/share.$code'
const ThemesRoute = ThemesRouteImport.update({
id: '/themes',
path: '/themes',
getParentRoute: () => rootRouteImport,
} as any)
const SectorsRoute = SectorsRouteImport.update({
id: '/sectors',
path: '/sectors',
getParentRoute: () => rootRouteImport,
} as any)
const HotMapRoute = HotMapRouteImport.update({
id: '/hot-map',
path: '/hot-map',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ThemeCodeRoute = ThemeCodeRouteImport.update({
id: '/theme/$code',
path: '/theme/$code',
getParentRoute: () => rootRouteImport,
} as any)
const StockCodeRoute = StockCodeRouteImport.update({
id: '/stock/$code',
path: '/stock/$code',
@@ -37,40 +55,81 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/hot-map': typeof HotMapRoute
'/sectors': typeof SectorsRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/sectors' | '/share/$code' | '/stock/$code'
fullPaths:
| '/'
| '/hot-map'
| '/sectors'
| '/themes'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/sectors' | '/share/$code' | '/stock/$code'
id: '__root__' | '/' | '/sectors' | '/share/$code' | '/stock/$code'
to:
| '/'
| '/hot-map'
| '/sectors'
| '/themes'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
id:
| '__root__'
| '/'
| '/hot-map'
| '/sectors'
| '/themes'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
HotMapRoute: typeof HotMapRoute
SectorsRoute: typeof SectorsRoute
ThemesRoute: typeof ThemesRoute
ShareCodeRoute: typeof ShareCodeRoute
StockCodeRoute: typeof StockCodeRoute
ThemeCodeRoute: typeof ThemeCodeRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/themes': {
id: '/themes'
path: '/themes'
fullPath: '/themes'
preLoaderRoute: typeof ThemesRouteImport
parentRoute: typeof rootRouteImport
}
'/sectors': {
id: '/sectors'
path: '/sectors'
@@ -78,6 +137,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SectorsRouteImport
parentRoute: typeof rootRouteImport
}
'/hot-map': {
id: '/hot-map'
path: '/hot-map'
fullPath: '/hot-map'
preLoaderRoute: typeof HotMapRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -85,6 +151,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/theme/$code': {
id: '/theme/$code'
path: '/theme/$code'
fullPath: '/theme/$code'
preLoaderRoute: typeof ThemeCodeRouteImport
parentRoute: typeof rootRouteImport
}
'/stock/$code': {
id: '/stock/$code'
path: '/stock/$code'
@@ -104,9 +177,12 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
HotMapRoute: HotMapRoute,
SectorsRoute: SectorsRoute,
ThemesRoute: ThemesRoute,
ShareCodeRoute: ShareCodeRoute,
StockCodeRoute: StockCodeRoute,
ThemeCodeRoute: ThemeCodeRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+1 -1
View File
@@ -20,5 +20,5 @@ export const getRouter = () => {
defaultPreloadStaleTime: 0,
});
return router;
return { router, queryClient };
};
File diff suppressed because it is too large Load Diff
+24 -10
View File
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2 } from "lucide-react";
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/")({
@@ -211,12 +211,26 @@ function Index() {
A股走势追踪
</h1>
<p className="text-sm md:text-base text-muted-foreground"></p>
<Link to="/sectors">
<Button variant="outline" size="sm" className="mt-3 gap-1.5 text-xs">
<TrendingUp className="h-3.5 w-3.5" />
</Button>
</Link>
<div className="mt-3 flex items-center justify-center gap-2">
<Link to="/sectors">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<TrendingUp className="h-3.5 w-3.5" />
</Button>
</Link>
<Link to="/themes">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<Flame className="h-3.5 w-3.5" />
</Button>
</Link>
<Link to="/hot-map">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<Network className="h-3.5 w-3.5" />
穿
</Button>
</Link>
</div>
</div>
<Card className="mb-6 md:mb-8 shadow-lg">
@@ -517,7 +531,7 @@ function CollectionCard({
<p className="text-sm text-muted-foreground text-center py-4"></p>
) : (
<div className="space-y-1">
{stocks.slice(0, 10).map((stock) => (
{stocks.slice(0, 20).map((stock) => (
<StockRowItem
key={stock.id}
stock={stock}
@@ -526,8 +540,8 @@ function CollectionCard({
swipeResetKey={swipeResetKey}
/>
))}
{stocks.length > 10 && (
<p className="text-xs text-muted-foreground text-center pt-1"> {stocks.length - 10} ...</p>
{stocks.length > 20 && (
<p className="text-xs text-muted-foreground text-center pt-1"> {stocks.length - 20} ...</p>
)}
</div>
)}
+255 -120
View File
@@ -1,49 +1,114 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect, useMemo } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchSectors, type SectorItem, type SectorType } from "@/lib/stock-api";
import { formatMoney } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { ArrowLeft, TrendingUp, TrendingDown, RefreshCw } from "lucide-react";
import { Link } from "@tanstack/react-router";
import {
ArrowLeft,
RefreshCw,
ArrowDown,
ArrowUp,
TrendingUp,
TrendingDown,
} from "lucide-react";
export const Route = createFileRoute("/sectors")({
component: SectorsPage,
});
/* ============================================================
Tab /
============================================================ */
const TABS: { key: SectorType; label: string }[] = [
{ key: "industry", label: "行业板块" },
{ key: "concept", label: "概念板块" },
{ key: "industry", label: "行业" },
{ key: "concept", label: "概念" },
];
type SortMode = "mainNetInflow" | "changePercent";
/* ============================================================
============================================================ */
type SortKey = "mainNetInflow" | "mainNetInflowPercent";
const SORT_LABEL: Record<SortKey, string> = {
mainNetInflow: "资金",
mainNetInflowPercent: "涨幅",
};
/* ============================================================
============================================================ */
function SectorsPage() {
const queryClient = useQueryClient();
const [tab, setTab] = useState<SectorType>("industry");
const [data, setData] = useState<SectorItem[]>([]);
const [loading, setLoading] = useState(true);
const [sort, setSort] = useState<SortMode>("mainNetInflow");
const [sortKey, setSortKey] = useState<SortKey>("mainNetInflow");
const [asc, setAsc] = useState(true); // 默认升序
const loadData = (t: SectorType) => {
setLoading(true);
fetchSectors(t).then((items) => {
setData(items);
setLoading(false);
// ── 行业 / 概念各自独立 Query,缓存完全隔离 ──
const industryQ = useQuery({
queryKey: ["sectors", "industry"],
queryFn: ({ signal }) => fetchSectors("industry", signal),
staleTime: 30_000,
retry: false,
});
const conceptQ = useQuery({
queryKey: ["sectors", "concept"],
queryFn: ({ signal }) => fetchSectors("concept", signal),
staleTime: 30_000,
retry: false,
});
// 当前激活的 tab 查询
const activeQuery = tab === "industry" ? industryQ : conceptQ;
const { isLoading, isFetching, isError, refetch } = activeQuery;
/*
*/
// ① 缓存数据 — React Query 从后端拿到的原始数据
const cachedData: SectorItem[] = activeQuery.data ?? [];
// ② 排序数据 — 按当前排序规则在内存中重排
const sortedData = useMemo<SectorItem[]>(() => {
const dir = asc ? 1 : -1;
return [...cachedData].sort((a, b) => {
const av =
sortKey === "mainNetInflow"
? a.mainNetInflow
: (a.mainNetInflowPercent ?? -Infinity);
const bv =
sortKey === "mainNetInflow"
? b.mainNetInflow
: (b.mainNetInflowPercent ?? -Infinity);
return (bv - av) * dir;
});
}, [cachedData, sortKey, asc]);
// ③ 展示数据 — 最终渲染的数据集(当前即排序数据,后续可加分页截断)
const displayData = sortedData;
// ── 切换板块:清空全部缓存 + 重新获取 ──
const handleTab = (t: SectorType) => {
if (t === tab) return;
setTab(t);
// 移除所有板块缓存,切换后对应的 useQuery 会自动 refetch
queryClient.removeQueries({ queryKey: ["sectors"] });
};
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]);
// ── 切换排序 ──
const toggleSort = (key: SortKey) => {
if (key === sortKey) {
setAsc((v) => !v);
} else {
setSortKey(key);
setAsc(true); // 切新维度默认升序
}
};
return (
<div className="min-h-screen bg-background">
{/* 顶栏 */}
{/* ── 顶栏 ── */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -53,68 +118,95 @@ function SectorsPage() {
<h1 className="text-base font-semibold"></h1>
</div>
<button
onClick={() => loadData(tab)}
onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
<RefreshCw
className={`h-4 w-4 ${isFetching ? "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">
{/* ── Tab 切换 ── */}
<div className="max-w-5xl mx-auto px-4 mt-4">
<div className="flex gap-1 bg-muted rounded-lg p-1">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
onClick={() => handleTab(t.key)}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
tab === t.key
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t.label}
{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 className="max-w-5xl mx-auto px-4 mt-3 flex items-center justify-between">
<p className="text-[10px] text-muted-foreground">
{cachedData.length}
{isFetching && (
<span className="ml-1 text-[10px] text-muted-foreground/60">
·
</span>
)}
</p>
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
<button
key={key}
onClick={() => toggleSort(key)}
className={`px-2.5 py-1 flex items-center gap-0.5 transition-colors ${
sortKey === key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{SORT_LABEL[key]}
{sortKey === key &&
(asc ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</button>
))}
</div>
</div>
{/* 数据信息 */}
<div className="max-w-5xl mx-auto px-4 mt-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 ? (
{/* 加载骨架 */}
{isLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-xl bg-muted h-32" />
<div key={i} className="animate-pulse rounded-xl bg-muted h-40" />
))}
</div>
) : isError ? (
/* 请求失败 */
<div className="flex flex-col items-center gap-3 py-20">
<p className="text-sm text-muted-foreground"></p>
<button
onClick={() => refetch()}
className="text-xs text-primary hover:underline"
>
</button>
</div>
) : cachedData.length === 0 ? (
/* 数据为空 */
<div className="text-center py-20 text-sm text-muted-foreground">
{tab === "industry" ? "行业" : "概念"}
</div>
) : (
/* 板块卡片网格 */
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{sorted.map((item) => (
<SectorBlock key={item.code} item={item} />
{displayData.map((item) => (
<SectorCard key={item.code} item={item} />
))}
</div>
)}
@@ -123,54 +215,27 @@ function SectorsPage() {
);
}
function formatInflow(val: number | null | undefined): string {
/* ============================================================
============================================================ */
function fmt(val: number | null | undefined, digits = 2): 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);
return val.toFixed(digits);
}
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;
/* ============================================================
============================================================ */
function SectorCard({ item }: { item: SectorItem }) {
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
);
const inflow = item.mainNetInflow;
const inflowIsPos = inflow >= 0;
const inflowPct = item.mainNetInflowPercent;
return (
<Card className="rounded-xl hover:shadow-md transition-shadow">
<CardContent className="p-3 space-y-2">
{/* 板块名称 */}
<CardContent className="p-3 space-y-1.5">
{/* 板块名称 + 代码 */}
<div className="flex items-center justify-between gap-1">
<p className="text-sm font-medium truncate" title={item.name}>
{item.name}
@@ -182,7 +247,7 @@ function SectorBlock({ item }: { item: SectorItem }) {
)}
</div>
{/* 涨跌幅 */}
{/* 涨跌幅 + 成交额 */}
<div className="flex items-center justify-between">
{change != null ? (
<span
@@ -196,36 +261,106 @@ function SectorBlock({ item }: { item: SectorItem }) {
<TrendingDown className="h-3 w-3" />
)}
{change >= 0 ? "+" : ""}
{change.toFixed(2)}%
{fmt(change)}%
</span>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
<span className="text-[10px] text-muted-foreground">
{formatInflow(item.turnover)}
{formatMoney(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>
{/* 分割线 */}
<hr className="border-border/40" />
{/* 资金流向明细条 */}
<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 className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground"></span>
<div className="flex items-center gap-2">
<span
className={`text-xs font-bold tabular-nums ${
inflowIsPos ? "text-red-500" : "text-green-500"
}`}
>
{inflow >= 0 ? "+" : ""}
{formatMoney(inflow)}
</span>
{inflowPct != null && (
<span
className={`text-[10px] tabular-nums ${
inflowIsPos ? "text-red-500/70" : "text-green-500/70"
}`}
>
{inflow >= 0 ? "+" : ""}
{fmt(inflowPct)}%
</span>
)}
</div>
</div>
{/* 资金流向明细条 */}
<FundFlowBreakdown item={item} />
</CardContent>
</Card>
);
}
/* ============================================================
/ / /
============================================================ */
const FLOW_LABELS = [
{ key: "superLargeInflow" as const, label: "超大单" },
{ key: "largeInflow" as const, label: "大单" },
{ key: "mediumInflow" as const, label: "中单" },
{ key: "smallInflow" as const, label: "小单" },
];
function FundFlowBreakdown({ item }: { item: SectorItem }) {
// 取所有流量的最大绝对值做归一化
const maxAbs = Math.max(
Math.abs(item.mainNetInflow),
Math.abs(item.superLargeInflow ?? 0),
Math.abs(item.largeInflow ?? 0),
Math.abs(item.mediumInflow ?? 0),
Math.abs(item.smallInflow ?? 0),
1,
);
return (
<div className="space-y-0.5">
{FLOW_LABELS.map((f) => {
const val = item[f.key];
if (val == null) return null;
const pct = maxAbs > 0 ? (Math.abs(val) / maxAbs) * 100 : 0;
const isPos = val >= 0;
return (
<div key={f.key} className="flex items-center gap-1.5">
<span className="text-[9px] text-muted-foreground w-6 shrink-0 text-right">
{f.label}
</span>
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden relative">
<div
className={`h-full rounded-full transition-all ${
isPos ? "bg-red-500/60 ml-1/2" : "bg-green-500/60"
}`}
style={{
width: `${Math.min(pct, 100)}%`,
marginLeft: isPos ? "50%" : undefined,
marginRight: isPos ? undefined : `${100 - Math.min(pct, 100)}%`,
}}
/>
</div>
<span
className={`text-[9px] font-medium tabular-nums w-14 text-right shrink-0 ${
isPos ? "text-red-500" : "text-green-500"
}`}
>
{formatMoney(val)}
</span>
</div>
);
})}
</div>
);
}
+66 -6
View File
@@ -63,8 +63,14 @@ interface StockInfo {
addedPrice: number | null;
outerDisk: number; // 外盘(手)
innerDisk: number; // 内盘(手)
volume: number; // 成交量(股)
amount: number; // 成交额(元)
quoteDate: string; // 行情日期 YYYY-MM-DD
turnoverRate: number; // 换手率%
pe: number; // 市盈率
pb: number; // 市净率
totalMarketCap: number; // 总市值
circulatingMarketCap: number; // 流通市值
}
function StockDetail() {
@@ -155,6 +161,12 @@ function StockDetail() {
innerDisk: quote.innerDisk || 0,
amount: (quote.amount || 0) * 10000,
quoteDate: quote.date,
volume: quote.volume || 0,
turnoverRate: quote.turnoverRate ?? 0,
pe: quote.pe ?? 0,
pb: quote.pb ?? 0,
totalMarketCap: quote.totalMarketCap ?? 0,
circulatingMarketCap: quote.circulatingMarketCap ?? 0,
});
// 基础信息已就绪,结束主loading,先渲染页面框架
@@ -267,6 +279,14 @@ function StockDetail() {
negativeDays: negative,
};
}, [fundFlowSlice]);
// 从财务数据中提取最新ROE(加权净资产收益率)
const latestROE = useMemo(() => {
if (!financialData?.data?.length) return null;
const latest = financialData.data[0];
const roe = latest.indicators["加权净资产收益率"];
return typeof roe === "number" ? roe : null;
}, [financialData]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -331,12 +351,8 @@ function StockDetail() {
ticks.push(added);
}
ticks.push(last);
return [...new Set(ticks)].sort((a, b) => {
// 按日期排序(MM/DD格式需按MM和DD比较)
const [am, ad] = a.split('/').map(Number);
const [bm, bd] = b.split('/').map(Number);
return am - bm || ad - bd;
});
// 保持chartData的原始顺序(已按日期升序排列),不做二次排序
return [...new Set(ticks)];
})();
// 东方财富市场标识:0=深圳(000/002/300), 1=上海(60), 6=科创板(688)
@@ -447,6 +463,48 @@ function StockDetail() {
)}
</div>
</div>
{/* 行情指标合并到顶部卡片 */}
<div className="mt-4 md:mt-5 pt-4 md:pt-5 border-t border-border/50">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 md:gap-3">
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5">(PE)</p>
<p className="text-sm md:text-base font-semibold">{stockInfo.pe > 0 ? stockInfo.pe.toFixed(2) : '-'}</p>
</div>
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5">(PB)</p>
<p className="text-sm md:text-base font-semibold">{stockInfo.pb > 0 ? stockInfo.pb.toFixed(2) : '-'}</p>
</div>
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5">ROE</p>
<p className={`text-sm md:text-base font-semibold ${latestROE !== null ? (latestROE > 0 ? 'text-red-500' : 'text-green-500') : ''}`}>
{latestROE !== null ? `${latestROE.toFixed(2)}%` : '加载中...'}
</p>
</div>
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5"></p>
<p className="text-sm md:text-base font-semibold">{stockInfo.turnoverRate > 0 ? `${stockInfo.turnoverRate}%` : '-'}</p>
</div>
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5"></p>
<p className="text-sm md:text-base font-semibold">
{stockInfo.volume > 0 ? `${(stockInfo.volume / 100).toLocaleString()}` : '-'}
</p>
</div>
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5"></p>
<p className="text-sm md:text-base font-semibold">{formatMoney(stockInfo.amount)}</p>
</div>
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5"></p>
<p className="text-sm md:text-base font-semibold">{stockInfo.totalMarketCap > 0 ? formatMoney(stockInfo.totalMarketCap) : '-'}</p>
</div>
<div className="text-center p-2 md:p-3 rounded-lg bg-muted/30">
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5"></p>
<p className="text-sm md:text-base font-semibold">{stockInfo.circulatingMarketCap > 0 ? formatMoney(stockInfo.circulatingMarketCap) : '-'}</p>
</div>
</div>
</div>
</CardContent>
</Card>
@@ -653,6 +711,8 @@ function StockDetail() {
</Card>
</div>
{/* Daily Data Table - Independent from fund flow */}
{chartData.length > 0 && (
<Card className="mt-4 md:mt-6 shadow-lg">
+355
View File
@@ -0,0 +1,355 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchThemeDetail, fetchThemeStocks, type ThemeStock } 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,
});
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();
};
const baseInfo = detail?.baseInfo;
const hotEvent = detail?.hotEvent;
const eventHistory = detail?.eventHistory ?? [];
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}
/>
{/* ── 相关新闻(可折叠) ── */}
{eventHistory.length > 0 && <NewsList items={eventHistory} />}
{/* ── 相关股票 ── */}
<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,
}: {
f3: number | null | undefined;
up: number | null | undefined;
down: number | null | undefined;
flat: number | null | undefined;
fex5: number | null | undefined;
total: number;
}) {
const isPos = (f3 ?? 0) >= 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>
)}
</CardContent>
</Card>
);
}
/* ============================================================
============================================================ */
function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string; newsPublishTime: number | null }[] }) {
const [expanded, setExpanded] = useState(false);
const shown = expanded ? items : items.slice(0, 2);
const fmtTime = (ts: number | null) => {
if (!ts) return "";
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
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">{items.length} </span>
</div>
<div className="space-y-2.5">
{shown.map((n, idx) => (
<div key={idx} className="space-y-0.5">
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
<p className="text-[10px] text-muted-foreground">
{n.newsMediaName}
{n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""}
</p>
</div>
))}
</div>
{items.length > 2 && (
<button
onClick={() => setExpanded((v) => !v)}
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5"
>
{expanded ? "收起" : `展开全部 ${items.length}`}
{expanded ? <ChevronUp className="h-3 w-3" /> : <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>
);
}
+244
View File
@@ -0,0 +1,244 @@
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>
<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>
);
}