Compare commits

73 Commits
Author SHA1 Message Date
SakurasanandClaude 48522cbfea chore: 每日采集时间从15:00调整到15:01
收盘后留1分钟等数据稳定,避免15:00整点采集到不完整数据。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 19:55:49 +08:00
SakurasanandClaude a6497caf73 fix: 无领涨股题材导致/themes页面崩溃(startsWith null)
东方财富题材列表存在 securityCode 为 null 的题材(如"铟"01726),
getStockBoard(null) 调用 startsWith 抛 TypeError,React 渲染崩溃,
页面被错误边界接管。修复 getStockBoard 使其对空值兜底返回主板,
同时保护全部 8 处调用方;ThemeItem.securityCode/securityName 类型
如实标注为 string|null。新增最小回归脚本复现并验证修复。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 18:49:27 +08:00
SakurasanandClaude 8cf4c40e2b feat: 热点穿透股票球最小间隔4px改为6px
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 20:12:09 +08:00
SakurasanandClaude 90569918a3 refactor: 删除板块资金流向,题材板块功能更全
- 删除前端 /sectors 页面、首页入口按钮、stock-api 板块类型与 fetchSectors
- 删除后端 /api/sectors 路由,main.py 移除注册
- eastmoney.py 移除板块数据段(_fetch_push2/_fetch_akshare/UT令牌管理),
  清理重复 import 与无用 datetime 子导入
- mootdx.py 移除板块降级方案(fetch_sector_list)
- routeTree.gen.ts 由 build 自动重新生成,移除 sectors 路由

题材热点/热点穿透/核心股已覆盖板块能力,功能更全,板块资金流向不再需要

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:50:54 +08:00
SakurasanandClaude b0dbeef3fd feat: 容器内统一运维脚本auvops.sh,recollect-daily并入
- 新增 backend/auvops.sh:容器内直接执行的统一运维工具
  (cache-clear/clean-expired/cache-count/recollect/sh/help)
- heredoc传python代码,日期等参数走环境变量防注入
- 后续运维操作收敛到该文件,加 cmd_xxx + case 注册一行即可
- 删除 recollect-daily.sh,其能力并入 auvops.sh recollect

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:57:27 +08:00
SakurasanandClaude eb66ab02d0 feat: 交易日9:31清空全部缓存,保证开盘后数据全新
- services/cache.py 新增 clear_all() 清空整个 cache 表
- daily_collector.py 新增 cache_cleanup_loop() 后台循环:精确 sleep 到最近一个
  工作日 9:31 清空缓存,跳过周末,出错1分钟重试
- main.py lifespan 启动清理任务,与采集任务一同优雅退出

与既有"非交易时段 TTL 截止到下次开盘前"的惰性过期形成双保险

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:56:56 +08:00
SakurasanandClaude 7fe8074e22 feat: 题材列表盘中120s缓存共用,题材详情新增全量新闻分页+强度热度行情
- 题材热点与热点穿透共用题材列表缓存:盘中由"不缓存实时拉取"改为120s短缓存,
  东财全量列表拉取降到每120s一次,图重建时涨幅/热度两榜直接命中缓存
- 详情页相关新闻升级为全量分页(getThemeRelatedNews):支持翻页+评论数,
  替换原 getDetail 固定3条
- 详情页统计条新增强度+热度(getSingleThemeQuote)实时指标

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 16:56:44 +08:00
SakurasanandClaude f98a9255a5 feat: 股票球按涨幅绝对值调整大小
半径 = 覆盖数基础 + 涨幅绝对值增量(|f3|/10*4, 封顶20),
涨得猛/跌得深球更大。同覆盖下 0%vs10% 球径差约4px。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 01:07:59 +08:00
SakurasanandClaude 776ab55fc3 feat: 股票球跌幅改为绿色空心球体
涨: 保留分级实心(覆盖数越多越红) + 红色光晕;
跌: 绿色空心球(绿描边+内部淡绿留白) + 绿色光晕。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:59:07 +08:00
SakurasanandClaude dfcce3abaf feat: 所有股票小球统一光晕强度,涨红光晕/跌绿光晕更明显
光晕透明度不再乘覆盖数 alpha(覆盖1的股票 alpha=0.45 导致光晕过淡),
改为所有股票统一 0.5 强度,高亮淡出时降为 0.06。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:50:52 +08:00
SakurasanandClaude d473bbad6b feat: 股票小球心跳动画(覆盖≥5题材) + 涨幅正红光晕/负绿光晕
- 覆盖≥5题材股票球按正弦脉动(周期900ms, 幅度22%), 越大越醒目
- 涨幅≥0红色径向渐变光晕, <0绿色光晕, 随心跳一起脉动
- 架构: 静态层不再画股票节点(只画边+题材), 股票常驻动态层重绘
  以支撑持续动画; 新增常驻rAF动画循环驱动 timeRef
- 高亮态邻居淡出时, 光晕/心跳随 alpha 同步淡出

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:44:42 +08:00
SakurasanandClaude c4a0c50ed0 feat: 题材小球正涨幅蓝实心、负涨幅蓝空心
负涨幅球改为仅描边空心(内部淡蓝留白),边框加粗;
图例同步改为空心蓝球示意。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 00:10:31 +08:00
SakurasanandClaude 0a32bfad78 feat: 题材小球整体放大(半径 6~30)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:53:47 +08:00
SakurasanandClaude 69535bc782 feat: 题材小球改平方根映射,拉大中小涨幅区分度
线性映射被极端涨幅拉平均,多数 0~3% 题材球挤在最小值附近。
改平方根映射:零涨幅 4px、1%≈10px、3%≈15px、极值 22px,
中等涨幅区间球径差显著放大。碰撞力半径动态跟随节点,无需额外适配。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:51:58 +08:00
SakurasanandClaude 254deb6a80 feat: 热点穿透题材节点按涨幅正蓝负绿、球大小随涨幅绝对值
- 后端题材列表下发 bf3(题材涨幅)
- 题材节点: 正涨幅蓝色/负涨幅绿色, 半径按 |bf3| 在 6~20 映射
- 题材标签文字颜色跟随正负
- 信息卡/底部浮层题材分支显示涨幅
- 图例拆分涨/跌两项并说明球大小含义

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:34:38 +08:00
SakurasanandClaude b29ca8413c feat: 核心股追踪改名热点股追踪
页面标题、页面内'活跃核心股'描述、入口链接文案同步更名;
热点穿透页内'穿透核心股'列表概念(覆盖题材数)保留原名。
底层路由/表名/代码标识符 core_stocks 不变。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 23:02:39 +08:00
SakurasanandClaude c610faa4ad chore: 新增服务器端清理重采脚本 recollect-daily.sh
选股逻辑变更后,服务器上当天旧数据需删除重采(幂等会跳过)。
脚本默认容器 auv、日期取服务器当天,支持显式传参;含日期格式与容器存在性校验。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 22:33:13 +08:00
SakurasanandClaude f93ffd381b feat: 核心股追踪选股并入热点穿透核心股(覆盖题材数降序前100,去重)
选股口径由仅'题材领涨股涨幅前100'改为:
- 热点穿透核心股(覆盖题材数≥2,按覆盖数降序前100)
- 合并题材领涨股涨幅前100,按股票代码去重

复用 _build_theme_graph 计算每股覆盖题材数;热点穿透构建失败
时降级为仅领涨股前100,不影响当日采集。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 22:31:38 +08:00
SakurasanandClaude 1d3ec3194c feat: 核心股追踪题材默认展开显示
移除展开/收起交互,题材标签区始终展示

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:56:18 +08:00
SakurasanandClaude b4d48180b7 feat: 核心股追踪题材标签跳题材详情、股票名跳股票详情
- 题材标签 → /theme/$code
- 股票名 → /stock/$code(阻止冒泡避免触发行展开)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:53:47 +08:00
SakurasanandClaude 6d3c470b7b feat: 核心股追踪行点击展开显示所属题材列表
- active 接口返回每只核心股 themes(窗口内按题材去重)
- 页面行点击展开题材标签,chevron 指示展开状态

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:50:16 +08:00
SakurasanandClaude 23644a9c77 feat: 题材涨幅前20存历史(原前10)
- daily_collector TOP_THEME_LIMIT 10 → 20
- 同步 routes/collector 注释与 spec/plan 文档
- 实测 2026-08-10 题材前20入库 20 条,themes/history 返回 20 条

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:27:54 +08:00
SakurasanandClaude 1c434a208a merge: 每日核心股/题材历史 + 活跃核心股滚动表格功能
将 worktree-core-stock-history 分支合并回 main:
- 每日采集核心股前100+所属题材、题材涨幅前10 存历史
- /api/core-stocks/active|history、/api/themes/history 接口
- /core-stocks 展示页(10日涨幅矩阵 + 题材数/最近上榜/上榜次数)
- 后端 asyncio 定时采集 + 前端入口链接

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:17:35 +08:00
SakurasanandClaude 036a87bac9 feat: 核心股页面补最近上榜日期列
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:13:58 +08:00
SakurasanandClaude 258fc9184d feat: 补齐 spec 遗漏 — themes/history 接口 + cover_count/coverCount/daysSinceLastAppear
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 21:08:39 +08:00
SakurasanandClaude ac985a2140 feat: 题材/热点穿透页加入核心股追踪入口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:59:12 +08:00
SakurasanandClaude a7cff0930d style: 核心股页面 formatGain 符号一致 + 表格 a11y 增强
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:56:38 +08:00
SakurasanandClaude 5e7f0c4f13 feat: 活跃核心股 10 日涨幅矩阵页面
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:50:39 +08:00
Sakurasan 77f6e36c13 fix: dailyGains 类型精度 + 补 try/catch 与 theme-api 对齐 2026-08-10 20:40:33 +08:00
Sakurasan cd56f6c158 feat: 核心股历史前端 API 客户端 2026-08-10 20:30:28 +08:00
SakurasanandClaude c58bd990fd fix: lifespan 关闭时 await 取消的采集任务,避免 pending 警告
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:23:17 +08:00
SakurasanandClaude 6b12b04cab feat: 挂载核心股路由并启动每日采集任务
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:20:55 +08:00
SakurasanandClaude ffabf3d396 perf: /history 消除题材查询 N+1 并修正排序注释
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:17:39 +08:00
SakurasanandClaude f5ba61e3db feat: 核心股历史/活跃接口
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 20:00:29 +08:00
SakurasanandClaude 526b182051 fix: 修复 collector_loop 常量名(NameError)并填充核心股所属题材
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:49:36 +08:00
SakurasanandClaude 14c31e3f06 fix: 修正 collector_loop 常量名拼写 _COLLECT_AFTER_TIME → COLLECT_AFTER_TIME
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:38:24 +08:00
SakurasanandClaude aed3eea739 feat: 每日核心股/题材采集服务(幂等)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:30:35 +08:00
Sakurasan 5ffa5c07b2 feat: 新增每日核心股/题材历史 3 张表 2026-08-10 19:27:51 +08:00
Sakurasan 84d237de1f docs: 每日核心股/题材历史功能实现计划 2026-08-10 19:26:20 +08:00
SakurasanandClaude f354da108d docs: 每日热点核心股/题材历史记录 + 活跃核心股滚动表格设计
- 每日采集核心股前100(按涨幅)+所属题材,题材涨幅前10,存历史
- asyncio 后台定时采集,幂等去重
- 新页面:股票×最近10个A股交易日涨幅矩阵,超10日未出现踢出

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:26:20 +08:00
Sakurasan 8687bda331 docs: 每日核心股/题材历史功能实现计划 2026-08-10 19:25:31 +08:00
SakurasanandClaude 75c7ff3670 docs: 每日热点核心股/题材历史记录 + 活跃核心股滚动表格设计
- 每日采集核心股前100(按涨幅)+所属题材,题材涨幅前10,存历史
- asyncio 后台定时采集,幂等去重
- 新页面:股票×最近10个A股交易日涨幅矩阵,超10日未出现踢出

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 19:20:11 +08:00
SakurasanandClaude 84a01fb7b1 fix: 补齐完整浏览器请求头并改用 web client,修复生产 403 拿不到题材数据
生产服务器请求东财题材列表/相关股票返回 403(缺 sec-* 头 + iOS client
被风控识别为爬虫),导致热点穿透重建失败、数据冻结在上个交易日。

- 请求头补齐 sec-ch-ua/sec-fetch-*/priority/dnt 等完整 Chrome 头
- payload client 由 iOS 改为 web(生产实测该组合正常返回)
- content-type 补 charset=UTF-8

生产实测:完整头 + web client 正常返回数据。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 18:36:27 +08:00
SakurasanandClaude ce131b2867 fix: 热点穿透盘中缓存过期改为同步重建,避免显示上个交易日旧图
fetch_theme_graph 原用 stale-while-revalidate:缓存过期先返回旧数据、
后台异步重建。缓存里若是上个交易日数据,过期后用户一直看到旧图,
重建完成前不会更新。

- 交易时段缓存过期/无缓存 → 同步重建(加锁去重),绝不返回旧数据
- 非交易时段过期 → 保留 stale-while-revalidate(行情无实时变化,秒开无害)

实测盘中同步重建约 7s,可接受。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 13:47:21 +08:00
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
33 changed files with 5167 additions and 793 deletions
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# auvops.sh - AUV 容器内运维工具(在容器内直接执行,非宿主机 docker exec
#
# 拷贝进容器 /app(backend 根)后运行。后续新增运维能力都收敛到这个文件:
# 加一个 cmd_xxx 函数 + 在 main 的 case 里注册一行即可。
#
# 用法(容器内):
# ./auvops.sh cache-clear # 清空全部缓存(当日数据全新)
# ./auvops.sh clean-expired # 只清理已过期的缓存
# ./auvops.sh cache-count # 查看缓存条数
# ./auvops.sh recollect [DATE] # 删除并重采指定交易日(默认当天)
# ./auvops.sh sh # 进入交互式 shell
# ./auvops.sh help # 查看帮助
#
# 依赖: 容器内 python 可用(能 import services.*),工作目录自动切到脚本所在目录。
#
# 示例:
# ./auvops.sh cache-clear
# ./auvops.sh recollect 2026-08-10
# ./auvops.sh cache-count
set -euo pipefail
# 容器内 backend 根:脚本所在目录(/app)
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${ROOT}"
# 容器内用 python;本机验证时可 PY=./venv/bin/python ./auvops.sh ...
PY="${PY:-python}"
"${PY}" -c "import services" >/dev/null 2>&1 || {
echo "❌ 无法在 ${ROOT} 下 import services(确认已在容器内 /app 且 python 可用)" >&2
exit 1
}
# 在容器内跑 python 代码。日期等参数通过环境变量传,避免拼接进代码字符串。
# 用法: py_env "KEY1=val1" "KEY2=val2" <<'EOF'
# <python 代码>
# EOF
py_env() {
local env_args=()
while [[ "$#" -gt 0 ]]; do
env_args+=("${1%%=*}=${1#*=}"); shift
done
env "${env_args[@]}" "${PY}" -
}
# ---- 子命令实现 ----
# 清空全部缓存(当日数据全新)
cmd_cache_clear() {
echo "① 清空全部缓存"
"${PY}" - <<'EOF'
from services.cache import clear_all
clear_all()
print(" cache 表已清空")
EOF
echo "✅ 缓存已清空"
}
# 只清理已过期缓存
cmd_clean_expired() {
echo "① 清理过期缓存"
"${PY}" - <<'EOF'
from services.cache import clean_expired
clean_expired()
print(" 过期缓存已清理")
EOF
echo "✅ 完成"
}
# 查看缓存条数
cmd_cache_count() {
"${PY}" - <<'EOF'
from database import get_connection
conn = get_connection()
try:
n = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
print(f"缓存条数: {n}")
finally:
conn.close()
EOF
}
# 删除指定交易日旧数据,并用新选股逻辑重新采集入库
cmd_recollect() {
local date="${1:-$(date +%F)}"
if ! [[ "${date}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "❌ 日期格式错误:${date}(应为 YYYY-MM-DD" >&2
exit 1
fi
echo "================================================"
echo "🚀 容器: ${HOSTNAME:-unknown} 交易日: ${date}"
echo "================================================"
echo "① 删除 ${date} 旧数据"
py_env "TARGET_DATE=${date}" <<'EOF'
import os
from database import get_connection
date = os.environ["TARGET_DATE"]
conn = get_connection()
try:
for tbl in ("daily_core_stocks", "daily_core_stock_themes", "daily_top_themes"):
cur = conn.execute("DELETE FROM " + tbl + " WHERE trade_date = ?", (date,))
print(f" {tbl}: 删除 {cur.rowcount} 行")
conn.commit()
finally:
conn.close()
EOF
echo "② 用新选股逻辑重采 ${date}"
py_env "TARGET_DATE=${date}" <<'EOF'
import asyncio
import os
from services.daily_collector import collect_daily
result = asyncio.run(collect_daily(os.environ["TARGET_DATE"]))
print(" 结果:", result)
EOF
echo "✅ 完成"
}
# 进入交互式 shell
cmd_sh() {
"${PY}" || true
}
# 帮助
usage() {
awk 'NR >= 2 && /^#/ { sub(/^# ?/, ""); print; next } NR >= 2 && !/^#/ { exit }' "${BASH_SOURCE[0]}"
}
# ---- 入口 ----
main() {
if [[ "$#" -eq 0 ]]; then
usage
exit 1
fi
local cmd="$1"; shift
case "${cmd}" in
cache-clear|cc) cmd_cache_clear "$@" ;;
clean-expired) cmd_clean_expired "$@" ;;
cache-count) cmd_cache_count "$@" ;;
recollect) cmd_recollect "$@" ;;
sh|shell|python) cmd_sh "$@" ;;
help|-h|--help) usage ;;
*) echo "❌ 未知命令: ${cmd}./auvops.sh help 查看用法)" >&2; exit 1 ;;
esac
}
main "$@"
+34
View File
@@ -37,6 +37,40 @@ CREATE TABLE IF NOT EXISTS cache (
value TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS daily_core_stocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
stock_name TEXT NOT NULL,
f3 REAL,
cover_count INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code)
);
CREATE TABLE IF NOT EXISTS daily_core_stock_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code, theme_code)
);
CREATE TABLE IF NOT EXISTS daily_top_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
bf3 REAL,
hot_rank INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, theme_code)
);
"""
+17 -3
View File
@@ -1,3 +1,4 @@
import asyncio
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -6,7 +7,8 @@ from contextlib import asynccontextmanager
from dotenv import load_dotenv
from database import init_db
from routes import stock, collections, shares, sectors
from routes import stock, collections, shares, themes, core_stocks
from services.daily_collector import collector_loop, cache_cleanup_loop
load_dotenv()
@@ -14,7 +16,18 @@ load_dotenv()
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
collector_task = asyncio.create_task(collector_loop())
cache_cleanup_task = asyncio.create_task(cache_cleanup_loop())
try:
yield
finally:
for t in (collector_task, cache_cleanup_task):
t.cancel()
for t in (collector_task, cache_cleanup_task):
try:
await t
except asyncio.CancelledError:
pass
app = FastAPI(title="AUV API", version="1.0.0", lifespan=lifespan)
@@ -30,7 +43,8 @@ app.add_middleware(
app.include_router(stock.router, prefix="/api/stock")
app.include_router(collections.router, prefix="/api/collections")
app.include_router(shares.router, prefix="/api/share")
app.include_router(sectors.router, prefix="/api/sectors")
app.include_router(themes.router, prefix="/api/themes")
app.include_router(core_stocks.router, prefix="/api/core-stocks")
# 生产模式:后端同时托管前端静态文件
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
+2 -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
+117
View File
@@ -0,0 +1,117 @@
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
from datetime import date as date_cls
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from database import get_connection, dict_from_row
router = APIRouter()
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
def _recent_trade_dates(conn, n: int = 10) -> list[str]:
"""最近 n 个有数据的交易日(升序)"""
rows = conn.execute(
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
(n,),
).fetchall()
return [r["trade_date"] for r in reversed(rows)]
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
async def active_core_stocks():
conn = get_connection()
try:
dates = _recent_trade_dates(conn, 10)
if not dates:
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
# 窗口内出现过且最近一次出现距今天数 <= 10 个交易日
placeholders = ",".join("?" * len(dates))
rows = conn.execute(
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count FROM daily_core_stocks
WHERE trade_date IN ({placeholders})
ORDER BY trade_date DESC, rank ASC""",
dates,
).fetchall()
# 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜
stock_days: dict[str, dict] = {}
for r in rows:
code = r["stock_code"]
s = stock_days.setdefault(code, {
"stockCode": code,
"stockName": r["stock_name"],
"coverCount": r["cover_count"],
"dailyGains": {},
"appearCount": 0,
"lastAppear": None,
})
s["dailyGains"][r["trade_date"]] = r["f3"]
s["appearCount"] += 1
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
s["lastAppear"] = r["trade_date"]
stocks = list(stock_days.values())
# 稳定排序:先按出现次数降序,再按最近上榜日降序
stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True)
stocks.sort(key=lambda x: -x["appearCount"])
# 一次性取窗口内全部题材关联,按 stock_code 分组(跨天题材去重)
themes_rows = conn.execute(
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
WHERE trade_date IN ({placeholders})""",
dates,
).fetchall()
themes_by_stock: dict[str, dict[str, dict]] = {}
for t in themes_rows:
per = themes_by_stock.setdefault(t["stock_code"], {})
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
for s in stocks:
s["themes"] = list(themes_by_stock.get(s["stockCode"], {}).values())
# daysSinceLastAppear:最近上榜距窗口最新交易日的自然日差(简单口径)
latest = dates[-1] if dates else None
for s in stocks:
if s.get("lastAppear") and latest:
try:
d1 = date_cls.fromisoformat(latest)
d2 = date_cls.fromisoformat(s["lastAppear"])
s["daysSinceLastAppear"] = (d1 - d2).days
except ValueError:
s["daysSinceLastAppear"] = 0
else:
s["daysSinceLastAppear"] = 0
return JSONResponse({"dates": dates, "stocks": stocks}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/history", summary="指定交易日核心股(含所属题材)")
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC", (date,)
).fetchall()
# 一次性取该日全部题材关联,按 stock_code 分组,避免逐股 N+1 查询
themes_rows = conn.execute(
"SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ?",
(date,),
).fetchall()
themes_by_stock: dict[str, list] = {}
for t in themes_rows:
themes_by_stock.setdefault(t["stock_code"], []).append(
{"theme_code": t["theme_code"], "theme_name": t["theme_name"]}
)
items = []
for r in rows:
d = dict_from_row(r)
d["themes"] = themes_by_stock.get(d["stock_code"], [])
items.append(d)
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
-17
View File
@@ -1,17 +0,0 @@
"""板块数据路由:行业板块、概念板块"""
from fastapi import APIRouter, Query, HTTPException
from services import eastmoney
router = APIRouter()
@router.get("", summary="板块列表")
async def sector_list(
type: str = Query("industry", description="板块类型:industry=行业板块, concept=概念板块"),
):
if type not in ("industry", "concept"):
raise HTTPException(status_code=400, detail="板块类型错误,仅支持 industry/concept")
data = await eastmoney.fetch_sector_list(type)
return {"data": data, "count": len(data), "type": type}
+37 -6
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}
+113
View File
@@ -0,0 +1,113 @@
"""题材数据路由:题材列表、题材详情、题材相关股票"""
from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import JSONResponse
from database import get_connection, dict_from_row
from services import themes
router = APIRouter()
# 上游反代/CDN 可能按 path 缓存,显式禁止缓存
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
@router.get("", summary="题材列表")
async def theme_list(
sort_field: int = Query(1, ge=1, le=5, description="排序字段:1=涨幅 3=强度 4=热度排名 5=成交额"),
asc: bool = Query(False, description="True=升序, False=降序"),
):
if sort_field not in (1, 3, 4, 5):
raise HTTPException(status_code=400, detail="排序字段仅支持 1/3/4/5")
data = await themes.fetch_theme_list(sort_field, asc)
return JSONResponse(
{"data": data, "count": len(data), "sort_field": sort_field, "asc": asc},
headers=_NO_CACHE_HEADERS,
)
@router.get("/graph", summary="热点穿透:题材-股票网状关系图")
async def theme_graph(
sort_field: int = Query(1, description="题材排序:1=涨幅 4=热度"),
top: int = Query(30, ge=1, le=60, description="题材数量"),
limit: int = Query(1000, ge=100, le=2000, description="下发的股票节点上限(按穿透度取前 N 只)"),
):
if sort_field not in (1, 4):
raise HTTPException(status_code=400, detail="排序字段仅支持 1(涨幅)/4(热度)")
result = await themes.fetch_theme_graph(sort_field, top, limit)
return JSONResponse(result, headers=_NO_CACHE_HEADERS)
@router.get("/history", summary="指定交易日题材涨幅前20")
async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC", (date,)
).fetchall()
items = [dict_from_row(r) for r in rows]
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/{theme_code}/news", summary="题材相关新闻(分页)")
async def theme_news(
theme_code: str,
page_num: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=50, description="每页条数"),
max_eu_time: str = Query("", description="分页游标(上一页返回的 maxEuTime)"),
):
result = await themes.fetch_theme_news(theme_code, page_num, max_eu_time, page_size)
if result is None:
return JSONResponse(
{"data": None, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
return JSONResponse(
{"data": result, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
@router.get("/{theme_code}/quote", summary="单题材实时行情(强度/热度/涨幅)")
async def theme_quote(theme_code: str):
result = await themes.fetch_theme_quote(theme_code)
if result is None:
return JSONResponse(
{"data": None, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
return JSONResponse(
{"data": result, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
@router.get("/{theme_code}/detail", summary="题材详情")
async def theme_detail(theme_code: str):
data = await themes.fetch_theme_detail(theme_code)
if not data:
return JSONResponse(
{"data": None, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
return JSONResponse(
{"data": data, "theme_code": theme_code},
headers=_NO_CACHE_HEADERS,
)
@router.get("/{theme_code}/stocks", summary="题材相关股票(全部)")
async def theme_stocks(theme_code: str):
result = await themes.fetch_theme_stocks(theme_code)
return JSONResponse(
{
"data": result.get("stockList", []),
"statistic": result.get("statistic", {}),
"total": result.get("total", 0),
"theme_code": theme_code,
},
headers=_NO_CACHE_HEADERS,
)
+13 -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(
@@ -56,3 +56,13 @@ def clean_expired():
conn.commit()
finally:
conn.close()
def clear_all():
"""清空全部缓存(每日开盘后 9:31 调用,保证当日数据全新)"""
conn = get_connection()
try:
conn.execute("DELETE FROM cache")
conn.commit()
finally:
conn.close()
+233
View File
@@ -0,0 +1,233 @@
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。
"""
import asyncio
import traceback
from datetime import datetime, time as dtime, timezone, timedelta
from typing import Optional
from database import get_connection
from services.cache import clear_all
from services.themes import fetch_theme_list, _build_theme_graph
_CST = timezone(timedelta(hours=8))
# 每天采集的后台任务:每 300 秒(5 分钟)检查一次
CHECK_INTERVAL_SECONDS = 300
COLLECT_AFTER_TIME = dtime(15, 1) # 收盘后 15:01 开始允许采集(留 1 分钟等收盘数据稳定)
CORE_STOCK_LIMIT = 100 # 题材领涨股涨幅前100
TOP_THEME_LIMIT = 20 # 题材涨幅前20
HOTMAP_TOP_N = 50 # 热点穿透采样题材数:涨幅榜+热度榜各取前50,合并(与热点穿透页一致)
HOTMAP_CORE_LIMIT = 100 # 热点穿透核心股前100(按覆盖题材数降序)
CORE_COVER_THRESHOLD = 2 # 热点穿透核心股门槛:覆盖题材数 ≥2
# 每日缓存清理:交易日 9:31 清空全部缓存,保证开盘后数据全新
CLEANUP_TIME = dtime(9, 31)
def _is_trading_day(d: datetime) -> bool:
"""仅按工作日判断:周一至周五视为交易日,不处理法定节假日"""
return d.weekday() < 5
def _has_collected(trade_date: str) -> bool:
"""当日核心股是否已采集"""
conn = get_connection()
try:
row = conn.execute(
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
(trade_date,),
).fetchone()
return row is not None
finally:
conn.close()
async def collect_daily(trade_date: str, dry_run: bool = False) -> dict:
"""采集指定交易日数据并入库。
核心股 = 题材领涨股涨幅前100 ∪ 热点穿透核心股(覆盖题材数≥2、按覆盖数降序前100),按股票代码去重。
热点穿透构建失败时降级为仅领涨股前100,不影响当日采集。
Args:
trade_date: YYYY-MM-DD
dry_run: True 只打印不写库(用于验证)
Returns:
{"core_count": int, "theme_count": int, "skipped": bool}
"""
if _has_collected(trade_date):
print(f"[collector] {trade_date} 已采集,跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 1. 拉取全部题材列表(含领涨股,作为当日全部股票的采样来源)
themes = await fetch_theme_list(1, False)
if not themes:
print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 领涨题材映射:securityCode -> [(themeCode, themeName), ...](用于核心股"所属题材")
lead_themes: dict[str, list[tuple[str, str]]] = {}
for t in themes:
code = t.get("securityCode")
if not code:
continue
lead_themes.setdefault(code, []).append((t["themeCode"], t["themeName"]))
# 2. 热点穿透核心股:覆盖题材数≥2,按覆盖题材数降序前100(去重)
# _build_theme_graph 采样 涨幅榜+热度榜 各前 HOTMAP_TOP_N 个题材并拉每股覆盖题材数,
# 已按 (-coverCount, -f3) 降序;失败时降级为空集,仅保留领涨股。
hotmap_core: list[dict] = []
graph_theme_name: dict[str, str] = {}
try:
graph = await _build_theme_graph(1, HOTMAP_TOP_N)
graph_theme_name = {t["themeCode"]: t["themeName"] for t in graph.get("themes", [])}
hotmap_core = [
s for s in graph.get("stocks", [])
if s.get("coverCount", 0) >= CORE_COVER_THRESHOLD
][:HOTMAP_CORE_LIMIT]
except Exception as e:
print(f"[collector] 热点穿透核心股构建失败,降级为仅领涨股: {e}")
# 3. 题材领涨股涨幅前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股)
stock_map: dict[str, dict] = {}
theme_count: dict[str, int] = {} # securityCode -> 领涨题材数
for t in themes:
code = t.get("securityCode")
if not code:
continue
theme_count[code] = theme_count.get(code, 0) + 1
if code not in stock_map or (t.get("f3") or 0) > (stock_map[code].get("f3") or 0):
stock_map[code] = {
"stock_code": code,
"stock_name": t.get("securityName", ""),
"f3": t.get("f3"),
}
f3_core = sorted(stock_map.values(), key=lambda x: -(x["f3"] or 0))[:CORE_STOCK_LIMIT]
# 4. 题材涨幅前20bf3 降序
top_themes = sorted(themes, key=lambda x: -(x.get("bf3") or 0))[:TOP_THEME_LIMIT]
# 5. 合并去重:热点穿透核心股在前(覆盖数降序,rank 优先),随后补领涨股涨幅前100
core_stocks: list[dict] = []
seen: set[str] = set()
theme_pairs: set[tuple[str, str, str]] = set() # (stock_code, theme_code, theme_name)
for s in hotmap_core:
code = s["securityCode"]
seen.add(code)
core_stocks.append({
"stock_code": code,
"stock_name": s.get("securityName", ""),
"f3": s.get("f3"),
"cover_count": s.get("coverCount", 0),
})
# 所属题材:采样榜内覆盖的题材 + 全量题材列表中的领涨题材
for tc in s.get("themeCodes", []):
theme_pairs.add((code, tc, graph_theme_name.get(tc, tc)))
for tc, tn in lead_themes.get(code, []):
theme_pairs.add((code, tc, tn))
for s in f3_core:
code = s["stock_code"]
if code in seen:
continue
seen.add(code)
core_stocks.append({
"stock_code": code,
"stock_name": s["stock_name"],
"f3": s["f3"],
"cover_count": theme_count.get(code, 0),
})
for tc, tn in lead_themes.get(code, []):
theme_pairs.add((code, tc, tn))
if dry_run:
print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只(热点穿透 {len(hotmap_core)} + 领涨股 {len(f3_core)}),题材前20 {len(top_themes)}")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
# 6. 入库(事务,UNIQUE 幂等)
conn = get_connection()
try:
for i, s in enumerate(core_stocks, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, cover_count, rank) VALUES (?,?,?,?,?,?)",
(trade_date, s["stock_code"], s["stock_name"], s["f3"], s["cover_count"], i),
)
for code, tc, tn in theme_pairs:
conn.execute(
"INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)",
(trade_date, code, tc, tn),
)
for i, t in enumerate(top_themes, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
(trade_date, t["themeCode"], t["themeName"], t.get("bf3"), t.get("hotRank"), i),
)
conn.commit()
finally:
conn.close()
print(f"[collector] {trade_date} 已采集:核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)}")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
"""后台循环:每个交易日 15:01 后自动采集当日数据(幂等)"""
while True:
try:
now = datetime.now(_CST)
if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME:
trade_date = now.strftime("%Y-%m-%d")
if not _has_collected(trade_date):
await collect_daily(trade_date)
except Exception:
print("[collector] 采集异常:")
traceback.print_exc()
if stop is not None and stop.is_set():
break
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
def _next_cleanup_dt(now: datetime) -> datetime:
"""计算下一个缓存清理时刻:最近一个工作日 9:31(今天已过则取下一个工作日)"""
for days in range(0, 8):
d = (now + timedelta(days=days)).date()
if d.weekday() >= 5: # 跳过周末
continue
dt = datetime(d.year, d.month, d.day, CLEANUP_TIME.hour, CLEANUP_TIME.minute, tzinfo=_CST)
if dt > now:
return dt
return now + timedelta(days=1) # 兜底:理论不可达
async def cache_cleanup_loop(stop: Optional[asyncio.Event] = None) -> None:
"""后台循环:每个交易日 9:31 清空全部缓存,保证开盘后读到全新数据
到点前精确 sleep 至 9:31;若进程在 9:31 后启动,则等下一个交易日。
"""
while True:
try:
now = datetime.now(_CST)
next_dt = _next_cleanup_dt(now)
delay = max(0, int((next_dt - now).total_seconds()))
if stop is not None:
try:
await asyncio.wait_for(stop.wait(), timeout=delay)
except asyncio.TimeoutError:
pass # 到点
if stop.is_set():
break
else:
await asyncio.sleep(delay)
clear_all()
print(f"[cache-cleanup] 已清空全部缓存: {datetime.now(_CST).strftime('%Y-%m-%d %H:%M:%S')}")
except asyncio.CancelledError:
raise
except Exception:
print("[cache-cleanup] 清理异常:")
traceback.print_exc()
await asyncio.sleep(60) # 出错 1 分钟后再试
+87 -248
View File
@@ -204,6 +204,75 @@ async def fetch_mx_api(name: str, days: int) -> Optional[List[dict]]:
return None
async def fetch_mx_tool(tool_query: str) -> Optional[dict]:
"""通用 MX 妙想 API 查询(自然语言 → 结构化数据,缓存 6 小时)
支持东方财富数据库的全品类查询,包括但不限于:
- A 股/港股/美股行情、财务、估值、股本、事件
- 基金净值、收益、持仓、排名
- 债券基本信息、久期凸性、信用评级
- 指数与板块行情、技术指标
- 宏观经济/行业经济/大宗商品数据
- 新闻研报、公告搜索
- 多条件选股/选基/选债
Args:
tool_query: 自然语言问句,如 "格力电器2024年营业收入和净利润"
"沪深300最新收盘价" "市盈率最低的50只股票"
Returns:
MX API 原始 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"):
@@ -212,250 +281,6 @@ def get_eastmoney_market(code: str) -> str:
return "1"
return "0"
# ---- 板块数据 ----
# 从东方财富 bkzj/list.js 逆向的字段映射
# f62=主力净流入, f184=主力净流入占比
# f66=超大单净流入, f69=超大单净流入占比
# f72=大单净流入, f75=大单净流入占比
# f78=中单净流入, f81=中单净流入占比
# f84=小单净流入, f87=小单净流入占比
# f70=成交额
SECTOR_FIELDS = "f12,f14,f2,f3,f62,f184,f66,f69,f72,f75,f78,f81,f84,f87,f70"
# 东方财富板块类型映射
SECTOR_MEDIA_MAP = {
"industry": "m:90+s:4",
"concept": "m:90+t:3",
}
# 东方财富 UT 令牌管理
_em_ut: str = "8dec03ba335b81bf4ebdf7b29ec27d15"
_em_ut_lock = asyncio.Lock()
async def _refresh_em_ut() -> str:
"""
从东方财富前端 JS 中提取最新的 ut 令牌。
按优先级尝试:
1. bkzj/list.js(板块页专用)
2. common/emdataview.js(通用数据组件)
"""
urls = [
"https://data.eastmoney.com/newstatic/js/bkzj/list.js",
"https://data.eastmoney.com/newstatic/js/common/emdataview.js",
]
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://data.eastmoney.com/bkzj/hy.html",
}
async with httpx.AsyncClient() as client:
for url in urls:
try:
resp = await client.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
continue
# 匹配 ut: 'xxxx' 或 ut:'xxxx' 或 ut: "xxxx"
m = re.search(r"""ut['"]?\s*:\s*['"]([a-f0-9]{32})['"]""", resp.text)
if m:
token = m.group(1)
print(f"[eastmoney] 已刷新 UT 令牌: {token[:8]}...")
return token
except Exception as e:
print(f"[eastmoney] 获取 UT 失败({url}): {e}")
return _em_ut # 保底返回当前值
async def get_em_ut(force_refresh: bool = False) -> str:
"""获取当前 UT,必要时刷新"""
global _em_ut
if force_refresh:
async with _em_ut_lock:
_em_ut = await _refresh_em_ut()
return _em_ut
# curl_cffi 模拟 Chrome TLS 指纹
from curl_cffi.requests import AsyncSession
_sector_session: Optional[AsyncSession] = None
def _get_sector_session() -> AsyncSession:
global _sector_session
if _sector_session is None:
_sector_session = AsyncSession(
impersonate="chrome120",
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://data.eastmoney.com/bkzj/hy.html",
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9",
},
timeout=10,
)
return _sector_session
# 内存缓存
_sector_cache: dict[str, tuple[list[dict], float]] = {}
_SECTOR_CACHE_TTL = 60
async def fetch_sector_list(sector_type: str) -> list[dict]:
now = time.time()
if sector_type in _sector_cache:
data, ts = _sector_cache[sector_type]
if now - ts < _SECTOR_CACHE_TTL:
return data
data = await _fetch_push2(sector_type)
if data:
_sector_cache[sector_type] = (data, now)
return data
data = await _fetch_akshare(sector_type)
if data:
_sector_cache[sector_type] = (data, now)
return data
async def _fetch_push2(sector_type: str) -> list[dict]:
"""东方财富 push2 APIcurl_cffi 模拟浏览器 TLS 指纹)"""
fs = SECTOR_MEDIA_MAP.get(sector_type)
if not fs:
return []
session = _get_sector_session()
ut = await get_em_ut()
url = (
f"https://push2.eastmoney.com/api/qt/clist/get"
f"?fs={fs}&fields={SECTOR_FIELDS}"
f"&fid=f62&po=1&pz=500&pn=1&np=1&fltt=2"
f"&invt=2&ut={ut}"
)
for attempt in range(2):
try:
resp = await session.get(url)
if resp.status_code != 200:
if attempt == 0:
await asyncio.sleep(1)
continue
return []
result = resp.json()
if result.get("rc") != 0:
return []
diff = result.get("data", {}).get("diff", [])
items = []
for item in diff:
items.append({
"code": item.get("f12", ""),
"name": item.get("f14", ""),
"level": item.get("f2"),
"changePercent": item.get("f3"),
"changeAmount": None,
"mainNetInflow": item.get("f62", 0) or 0,
"mainNetInflowPercent": item.get("f184", 0),
"superLargeInflow": item.get("f66", 0) or 0,
"superLargeInflowPercent": item.get("f69", 0),
"largeInflow": item.get("f72", 0) or 0,
"largeInflowPercent": item.get("f75", 0),
"mediumInflow": item.get("f78", 0) or 0,
"mediumInflowPercent": item.get("f81", 0),
"smallInflow": item.get("f84", 0) or 0,
"smallInflowPercent": item.get("f87", 0),
"turnover": item.get("f70", 0) or 0,
})
return items
except Exception as e:
err = str(e)
print(f"[eastmoney] push2 获取{sector_type}板块失败(attempt {attempt+1}): {err[:80]}")
# UT 可能过期,尝试刷新
if "disconnect" in err.lower() or "refused" in err.lower() or attempt == 1:
await get_em_ut(force_refresh=True)
ut = _em_ut
# 重建会话(TLS 指纹可能会被缓存)
global _sector_session
_sector_session = None
session = _get_sector_session()
if attempt == 0:
await asyncio.sleep(1)
return []
import akshare as ak
async def _fetch_akshare(sector_type: str) -> list[dict]:
"""akshare 降级方案(同花顺数据源)"""
loop = asyncio.get_event_loop()
def _get():
code_map = {}
try:
if sector_type == "industry":
code_df = ak.stock_board_industry_name_em()
else:
code_df = ak.stock_board_concept_name_em()
if code_df is not None and not code_df.empty:
for _, r in code_df.iterrows():
code_map[str(r.get("f14", ""))] = str(r.get("f12", ""))
except Exception:
try:
if sector_type == "industry":
code_df = ak.stock_board_industry_name_ths()
else:
code_df = ak.stock_board_concept_name_ths()
if code_df is not None and not code_df.empty:
for _, r in code_df.iterrows():
code_map[str(r.get("name", ""))] = str(r.get("code", ""))
except Exception:
pass
if sector_type == "industry":
df = ak.stock_fund_flow_industry()
else:
df = ak.stock_fund_flow_concept()
return code_map, df
try:
code_map, df = await loop.run_in_executor(None, _get)
if df is None or df.empty:
return []
df = df.sort_values("净额", ascending=False)
items = []
for _, row in df.iterrows():
name = str(row.get("行业", "")).strip()
inflow = float(row.get("流入资金", 0) or 0) * 100000000
outflow = float(row.get("流出资金", 0) or 0) * 100000000
items.append({
"code": code_map.get(name, ""),
"name": name,
"level": float(row.get("行业指数") or 0),
"changePercent": float(row.get("行业-涨跌幅") or 0),
"changeAmount": None,
"mainNetInflow": float(row.get("净额", 0) or 0) * 100000000,
"mainNetInflowPercent": None,
"superLargeInflow": None,
"superLargeInflowPercent": None,
"largeInflow": None,
"largeInflowPercent": None,
"mediumInflow": None,
"mediumInflowPercent": None,
"smallInflow": None,
"smallInflowPercent": None,
"turnover": inflow + outflow,
})
return items
except Exception as e:
print(f"[eastmoney] akshare 获取{sector_type}板块失败: {e}")
return []
# ---- 公司概况 ----
_F10_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"}
@@ -886,7 +711,6 @@ async def fetch_kline_history(code: str, days: int = 90) -> Optional[list[dict]]
"turnoverRate": _f(10),
})
parsed.reverse()
return parsed
except Exception as e:
if attempt == 0:
@@ -915,16 +739,31 @@ async def fetch_fund_flow_daykline(code: str, days: int) -> Optional[list[dict]]
"Referer": "https://quote.eastmoney.com/",
}
from curl_cffi.requests import AsyncSession
for attempt in range(2):
async with httpx.AsyncClient() as client:
async with AsyncSession(impersonate="chrome120") as session:
try:
resp = await client.get(url, headers=headers, timeout=10)
resp = await session.get(url, headers=headers, timeout=10)
if resp.status_code != 200:
if attempt == 0:
continue
return None
result = resp.json()
klines = result.get("data", {}).get("klines", [])
if not isinstance(result, dict):
if attempt == 0:
continue
return None
if result.get("rc", -1) != 0:
if attempt == 0:
continue
return None
data = result.get("data")
if not data or not isinstance(data, dict):
if attempt == 0:
continue
return None
klines = data.get("klines", [])
if not klines:
if attempt == 0:
continue
+70
View File
@@ -0,0 +1,70 @@
"""通达信数据源(mootdx TCP直连通达信服务器)
通过 TCP 协议直连通达信行情服务器,不走 HTTP,不会被限流。
主要用途:
- K线数据:主数据源(稳定可靠)
"""
import asyncio
from typing import Optional, List
def _get_market(code: str) -> str:
return "sh" if code.startswith("6") else "sz"
def _create_client():
from mootdx.quotes import Quotes
return Quotes.factory(market="std", multithread=True, heartbeat=True)
def _sync_fetch_kline(code: str, days: int) -> Optional[list]:
client = _create_client()
klines = client.bars(symbol=code, frequency=9, offset=min(days, 800))
if klines is None or len(klines) == 0:
return None
result = []
prev_close = 0.0
for bar in reversed(klines):
close = float(bar.close)
change_pct = 0
if prev_close > 0:
change_pct = (close - prev_close) / prev_close * 100
result.append(
{
"date": (
bar.datetime.strftime("%Y-%m-%d")
if hasattr(bar.datetime, "strftime")
else str(bar.datetime)[:10]
),
"open": float(bar.open),
"close": close,
"high": float(bar.high),
"low": float(bar.low),
"volume": int(bar.vol) if hasattr(bar, "vol") else 0,
"turnover": (
float(bar.amount) if hasattr(bar, "amount") and bar.amount else 0
),
"changePercent": round(change_pct, 2),
}
)
prev_close = close
result.sort(key=lambda x: x["date"])
return result
async def fetch_kline_history(code: str, days: int = 90) -> Optional[List[dict]]:
"""获取日K线(TCP直连通达信,主数据源)"""
try:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _sync_fetch_kline, code, days)
except ImportError:
return None
except Exception as e:
print(f"[mootdx] fetch_kline error: {e}")
return None
+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
+502
View File
@@ -0,0 +1,502 @@
"""东方财富题材数据服务:题材列表、题材详情、题材相关股票
逆向自 emrnweb.eastmoney.com/investment 的 H5 接口:
- 题材列表: POST https://emcfgdata.eastmoney.com/api/themeInvest/getThemeList
- 题材详情: GET https://emcfgdata.securities.eastmoney.com/api/themeInvest/getDetail/{themeCode}
- 相关股票: POST https://emcfgdata.eastmoney.com/api/themeInvest/getStockList
两个 POST 接口需要「移动端包装结构」:
{args:{...业务参数}, appKey, client, clientVersion, clientType, randomCode, timestamp}
"""
import asyncio
import json
import random
import string
import time
from datetime import datetime, time as dtime, timedelta, timezone
from typing import Optional
import httpx
from services.cache import get_cache, set_cache
# ---- 东方财富移动端配置中心域名 ----
_PZ_URL = "https://emcfgdata.eastmoney.com"
_PZ_CDN_URL = "https://emcfgdata.securities.eastmoney.com"
# appKey 与页面场景对应:题材列表索引页 / 题材详情页
_APP_KEY_INDEX = "rn-themeIndex"
_APP_KEY_DETAIL = "rn-themeDetail"
# 完整浏览器请求头:生产实测东财对缺 sec-* 头的移动端包装结构请求会 403,
# 补齐真实 Chrome 头 + client="web" 后正常返回
_HEADERS = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Content-Type": "application/json;charset=UTF-8",
"DNT": "1",
"Origin": "https://emrnweb.eastmoney.com",
"Priority": "u=1, i",
"Referer": "https://emrnweb.eastmoney.com/",
"Sec-Ch-Ua": '"Not=A?Brand";v="99", "Google Chrome";v="151", "Chromium";v="151"',
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"macOS"',
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
}
# ---- 交易时段感知缓存 ----
_CST = timezone(timedelta(hours=8)) # 北京时间
_TRADING_MORNING = (dtime(9, 30), dtime(11, 30))
_TRADING_AFTERNOON = (dtime(13, 0), dtime(15, 0))
def _is_trading_time() -> bool:
"""判断当前是否为 A 股交易时段(周一至周五 9:30-11:30 / 13:00-15:00"""
now = datetime.now(_CST)
if now.weekday() >= 5:
return False
t = now.time()
return (_TRADING_MORNING[0] <= t <= _TRADING_MORNING[1]
or _TRADING_AFTERNOON[0] <= t <= _TRADING_AFTERNOON[1])
def _next_open_delta_seconds() -> int:
"""非交易时段写入的缓存距下次开盘的秒数。
缓存只允许存活到下一次开盘(早盘 9:30 / 午休后 13:00)前一刻,
保证交易日开盘后缓存必然过期并实时拉取,不会读到上个交易日写入的旧数据。
"""
now = datetime.now(_CST)
# 午休 11:30-13:00 → 截止今天 13:00
if _TRADING_MORNING[1] < now.time() < _TRADING_AFTERNOON[0]:
open_dt = now.replace(hour=13, minute=0, second=0, microsecond=0)
return max(0, int((open_dt - now).total_seconds()))
# 其余非交易时段(早盘前 / 收盘后 / 周末 / 节假日)→ 下一个工作日 9:30
for days in range(0, 8):
d = (now + timedelta(days=days)).date()
if d.weekday() >= 5: # 跳过周末
continue
open_dt = datetime(d.year, d.month, d.day, 9, 30, tzinfo=_CST)
if open_dt > now:
return max(0, int((open_dt - now).total_seconds()))
return 0
# 盘中题材列表短缓存:题材热点页与热点穿透聚合共用同一份缓存,避免重复拉全量列表打东财。
# 120s 长于热点穿透图缓存(60s),图重建时必然命中且更新频率更低,两页数据更稳。
_LIST_CACHE_SECONDS = 120
def _list_ttl_seconds() -> int:
"""题材列表缓存秒数:交易时段 120s 短缓存;非交易时段缓存到下次开盘前失效"""
return _LIST_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds()
# ---- 请求封装 ----
def _build_payload(args: Optional[dict] = None, app_key: str = _APP_KEY_INDEX) -> dict:
"""构建东方财富移动端请求包装结构"""
return {
"args": args or {},
"appKey": app_key,
"client": "web", # 生产实测 iOS client 会 403web + 完整浏览器头正常
"clientVersion": "8.3",
"clientType": "cfw",
"randomCode": "".join(random.choices(string.ascii_uppercase + string.ascii_lowercase + string.digits, k=16)),
"timestamp": int(time.time() * 1000),
}
async def _post(path: str, args: dict, app_key: str = _APP_KEY_INDEX) -> Optional[dict]:
"""POST 到配置中心接口,返回 data 层 JSON"""
payload = _build_payload(args, app_key)
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(_PZ_URL + path, json=payload, headers=_HEADERS)
if resp.status_code != 200:
return None
body = resp.json()
except Exception as e:
print(f"[themes] POST {path} 失败: {e}")
return None
if body.get("code") != 0:
print(f"[themes] POST {path} 返回错误: {body.get('message')}")
return None
return body.get("data")
async def _get_cdn(path: str, app_key: str = _APP_KEY_DETAIL) -> Optional[dict]:
"""GET 到配置中心 CDN 接口(题材详情),data 包装结构放 query 参数"""
payload = _build_payload({}, app_key)
params = {"data": json.dumps(payload, ensure_ascii=False)}
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(_PZ_CDN_URL + path, params=params, headers=_HEADERS)
if resp.status_code != 200:
return None
body = resp.json()
except Exception as e:
print(f"[themes] GET {path} 失败: {e}")
return None
if body.get("code") != 0:
print(f"[themes] GET {path} 返回错误: {body.get('message')}")
return None
return body.get("data")
# ---- 题材列表 ----
# sortField 映射(题材列表页):1=涨幅(bf3) 3=强度(strengthValue) 4=热度排名(hotRank) 5=成交额(fex5)
_LIST_PAGE_SIZE = 500
async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict]:
"""获取全部题材列表(内部循环分页拉全,约 623 个,最多 2 页)
Args:
sort_field: 排序字段 1/3/4/5
asc: True=升序, False=降序
"""
cache_key = f"theme_list:{sort_field}:{asc}"
# 统一读缓存(盘中 TTL=120s 短缓存,非盘中缓存到下次开盘前失效),避免重复拉全量列表打东财
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
sort = 1 if asc else -1
# hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序
if sort_field == 4:
sort = -sort
items: list[dict] = []
page = 1
total = None
for _ in range(5): # 安全上限
data = await _post(
"/api/themeInvest/getThemeList",
{"pageSize": _LIST_PAGE_SIZE, "pageNum": page, "sort": sort, "sortField": sort_field},
)
if not data:
break
if total is None:
total = data.get("total", 0)
page_items = data.get("list", [])
if not page_items:
break
items.extend(page_items)
if len(items) >= total:
break
page += 1
if items:
ttl_s = _list_ttl_seconds()
if ttl_s > 0:
set_cache(cache_key, json.dumps(items, ensure_ascii=False), ttl_seconds=ttl_s)
return items
# ---- 题材详情 ----
async def fetch_theme_detail(theme_code: str) -> Optional[dict]:
"""获取题材详情(简介 + 热点事件 + 相关新闻),缓存 1 小时"""
cache_key = f"theme_detail:{theme_code}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
data = await _get_cdn(f"/api/themeInvest/getDetail/{theme_code}", app_key=_APP_KEY_DETAIL)
if not data:
return None
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_hours=1)
return data
# ---- 题材相关股票 ----
_STOCK_PAGE_SIZE = 100
async def fetch_theme_stocks(theme_code: str) -> dict:
"""获取题材下全部相关股票(分页拉全),返回 {stockList, statistic, total}"""
cache_key = f"theme_stocks:{theme_code}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
stock_list: list[dict] = []
statistic = {}
total = 0
page = 1
for _ in range(20): # 安全上限
data = await _post(
"/api/themeInvest/getStockList",
{"themeCode": theme_code, "pageSize": _STOCK_PAGE_SIZE, "pageNum": page, "sort": -1, "sortField": "f3"},
app_key=_APP_KEY_DETAIL,
)
if not data:
break
if not statistic and data.get("statistic"):
statistic = data["statistic"]
page_items = data.get("stockList", [])
if not page_items:
break
stock_list.extend(page_items)
total = data.get("total", 0)
if len(stock_list) >= total:
break
page += 1
result = {"stockList": stock_list, "statistic": statistic, "total": total}
if stock_list:
ttl_s = _graph_ttl_seconds()
if ttl_s > 0:
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_seconds=ttl_s)
return result
# ---- 热点穿透:题材-股票 网状关系图数据 ----
# 合并采样:涨幅榜 Top N + 热度榜 Top N 去重合并,
# 避免单一榜单导致股票覆盖题材数被低估(如有研新材覆盖 10+ 题材,仅涨幅榜只能采到 2 个)。
# 盘中图聚合结果/题材股票子层的缓存秒数。交易时段数据波动快,用 60s 短缓存;
# 非交易时段缓存 18 小时(覆盖到下一交易日)。
_GRAPH_CACHE_SECONDS = 60
def _graph_ttl_seconds() -> int:
"""图聚合结果与题材股票子层的缓存秒数:盘中 60 秒;非盘中缓存到下次开盘前失效"""
return _GRAPH_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds()
# 后台重建锁:cache_key -> asyncio.Lock,幂等去重,防止并发重复聚合
_REBUILD_LOCKS: dict[str, asyncio.Lock] = {}
def _set_graph_cache(cache_key: str, data: dict) -> None:
"""写入图聚合缓存(存 data + built_at + expires_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"], "bf3": t.get("bf3")}
for t in theme_map.values()
],
"stocks": stocks,
"stats": stats,
}
def _trim_graph_result(result: dict, limit: int) -> dict:
"""按 limit 裁剪 stocks(保留穿透度最高的 N 只),仅影响下发体积,不影响 coverCount 统计"""
return {
"themes": result.get("themes", []),
"stocks": result.get("stocks", [])[:limit],
"stats": result.get("stats", {}),
}
def _spawn_rebuild(cache_key: str, sort_field: int, top_n: int) -> None:
"""幂等触发后台重建:已有重建任务在跑则跳过"""
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
if lock.locked():
return
asyncio.create_task(_rebuild_task(cache_key, sort_field, top_n, lock))
async def _rebuild_task(cache_key: str, sort_field: int, top_n: int, lock: asyncio.Lock) -> None:
"""后台重建:拿锁后 double-check 缓存是否已被刷新,避免重复聚合"""
async with lock:
try:
data, expires_at = _get_graph_cache(cache_key)
if data is not None and expires_at and expires_at > time.time():
return # 已被其他任务刷新
data = await _build_theme_graph(sort_field, top_n)
_set_graph_cache(cache_key, data)
print(f"[themes] graph 后台重建完成: {cache_key}")
except Exception as e:
print(f"[themes] graph 后台重建失败: {cache_key} {e}")
async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1000) -> dict:
"""获取热点穿透图数据(盘中 60s 缓存,盘中过期同步重建,非盘中 stale-while-revalidate
缓存新鲜 → 直接返回;
非交易时段过期 → 返回旧数据并后台异步重建(秒开,非盘中行情无实时变化,旧值可接受);
交易时段过期 / 无缓存 → 同步重建(加锁去重),绝不返回上个交易日的旧图。
Args:
sort_field: 题材排序 1=涨幅, 4=热度
top_n: 每个榜单的题材数量(1-60)
limit: 下发 stocks 上限(穿透度最高的 N 只)
"""
cache_key = f"theme_graph:{sort_field}:{top_n}"
data, expires_at = _get_graph_cache(cache_key)
if data is not None and expires_at and expires_at > time.time():
# 缓存新鲜 → 直接返回
return _trim_graph_result(data, limit)
# 非交易时段过期:行情无实时变化,先返回旧值(秒开),后台异步重建
if data is not None and not _is_trading_time():
_spawn_rebuild(cache_key, sort_field, top_n)
return _trim_graph_result(data, limit)
# 交易时段过期 / 无缓存:同步重建,加锁去重
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
async with lock:
data, expires_at = _get_graph_cache(cache_key)
if data is not None and expires_at and expires_at > time.time():
# 等待锁期间已被其他请求刷新
return _trim_graph_result(data, limit)
data = await _build_theme_graph(sort_field, top_n)
_set_graph_cache(cache_key, data)
return _trim_graph_result(data, limit)
# ---- 题材相关新闻(分页) ----
_NEWS_PAGE_SIZE = 10
async def fetch_theme_news(theme_code: str, page_num: int = 1, max_eu_time: str = "", page_size: int = _NEWS_PAGE_SIZE) -> Optional[dict]:
"""获取题材相关新闻(分页),返回 {total, maxEuTime, list}
maxEuTime 为游标:上一页返回的 maxEuTime 作为下一页入参,首页传空串。
盘中 60s 短缓存(与图缓存同频);非盘中缓存到下次开盘前失效。
"""
cache_key = f"theme_news:{theme_code}:{page_num}:{max_eu_time}:{page_size}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
data = await _post(
"/api/themeInvest/getThemeRelatedNews",
{"themeCode": theme_code, "pageNum": page_num, "maxEuTime": max_eu_time, "pageSize": page_size},
app_key=_APP_KEY_DETAIL,
)
if not data:
return None
ttl_s = _graph_ttl_seconds()
if ttl_s > 0:
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
return data
# ---- 单题材实时行情(强度/热度/涨幅) ----
async def fetch_theme_quote(theme_code: str) -> Optional[dict]:
"""获取单题材实时行情(strengthValue/hotValue/f3),盘中 60s 短缓存"""
cache_key = f"theme_quote:{theme_code}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
data = await _post(
"/api/themeInvest/getSingleThemeQuote",
{"themeCode": theme_code},
app_key=_APP_KEY_DETAIL,
)
if not data:
return None
ttl_s = _graph_ttl_seconds()
if ttl_s > 0:
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
return data
@@ -0,0 +1,704 @@
# 每日核心股/题材历史 + 活跃核心股滚动表格 — 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 每日收盘后采集核心股前100(按涨幅)+所属题材、题材涨幅前20存历史,并提供「活跃核心股×最近10个A股交易日」涨幅矩阵展示页。
**Architecture:** 后端新增 asyncio 后台定时采集任务(`lifespan` 启动),复用现有 `services/themes.py``fetch_theme_list`/`fetch_theme_graph` 拿数据,写入 3 张新表;新增 `GET /api/core-stocks/active` 接口计算活跃窗口;前端新增 `/core-stocks` 路由渲染股票×10日涨幅矩阵。
**Tech Stack:** Python 3.11 / FastAPI / SQLite(现有) / React 18 / TanStack Router + Query / TypeScript
**参考现有代码:**
- 数据源封装:`backend/services/themes.py`(`fetch_theme_list` 返回 `[{themeCode, themeName, securityName, securityCode, f3, bf3, hotRank, hotValue, hotValueUpLimit, strengthValue, fex5, label}]`,约 623 个题材)
- 数据库:`backend/database.py`(`SCHEMA_SQL` + `init_db()` + `get_connection()`)
- 路由范式:`backend/routes/shares.py`(`get_connection()` + `dict_from_row`)
- 前端 API 范式:`src/lib/theme-api.ts`(`getApiBaseUrl()` + `fetch`)
- 前端页面范式:`src/routes/themes.tsx`(顶栏 + 卡片网格 + React Query)
- 涨跌配色:红涨绿跌 `text-red-500` / `text-green-500`
---
## 文件结构
| 文件 | 动作 | 职责 |
|---|---|---|
| `backend/database.py` | 修改 | 追加 3 张表 DDL |
| `backend/services/daily_collector.py` | 新建 | 后台定时采集 + 幂等入库 |
| `backend/routes/core_stocks.py` | 新建 | `/api/core-stocks/active` 等历史接口 |
| `backend/main.py` | 修改 | lifespan 启动采集任务 + 挂载路由 |
| `src/lib/core-stock-api.ts` | 新建 | 前端 API 客户端 |
| `src/routes/core-stocks.tsx` | 新建 | 展示页 |
| `src/routes/themes.tsx` | 修改 | 加入口链接 |
| `src/routes/hot-map.tsx` | 修改 | 加入口链接 |
| `backend/verify_collector.py` | 新建(临时) | 验证脚本,跑完删除 |
---
### Task 1: 数据库 — 追加 3 张表
**Files:**
- Modify: `backend/database.py`(在 `SCHEMA_SQL` 末尾追加)
- [ ] **Step 1: 修改 `SCHEMA_SQL`,追加建表语句**
`backend/database.py``SCHEMA_SQL` 字符串中,`cache` 表定义后追加:
```python
CREATE TABLE IF NOT EXISTS daily_core_stocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
stock_name TEXT NOT NULL,
f3 REAL,
cover_count INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code)
);
CREATE TABLE IF NOT EXISTS daily_core_stock_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code, theme_code)
);
CREATE TABLE IF NOT EXISTS daily_top_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
bf3 REAL,
hot_rank INTEGER,
rank INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, theme_code)
);
```
- [ ] **Step 2: 运行验证建表**
```bash
cd backend && ./venv/bin/python -c "from database import init_db; init_db(); from database import get_connection; c=get_connection(); t=[r['name'] for r in c.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'daily_%'\")]; print(t); c.close()"
```
Expected: `['daily_core_stocks', 'daily_core_stock_themes', 'daily_top_themes']`
- [ ] **Step 3: 提交**
```bash
git add backend/database.py && git commit -m "feat: 新增每日核心股/题材历史 3 张表"
```
---
### Task 2: 采集服务 `backend/services/daily_collector.py`
**Files:**
- Create: `backend/services/daily_collector.py`
- [ ] **Step 1: 新建采集服务**
```python
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。
"""
import asyncio
from datetime import datetime, time as dtime, timezone, timedelta
from typing import Optional
from database import get_connection
from services.themes import fetch_theme_list
_CST = timezone(timedelta(hours=8))
# 每天采集的后台任务:每 CHECK_INTERVAL 分钟检查一次
CHECK_INTERVAL_SECONDS = 300
COLLECT_AFTER_TIME = dtime(15, 0) # 收盘后 15:00 开始允许采集
CORE_STOCK_LIMIT = 100 # 核心股前100
TOP_THEME_LIMIT = 20 # 题材涨幅前20
def _is_trading_day(d: datetime) -> bool:
"""周一至周五视为交易日(与 themes._is_trading_time 一致,不处理法定节假日)"""
return d.weekday() < 5
def _has_collected(trade_date: str) -> bool:
"""当日核心股是否已采集"""
conn = get_connection()
try:
row = conn.execute(
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
(trade_date,),
).fetchone()
return row is not None
finally:
conn.close()
async def collect_daily(trade_date: str, dry_run: bool = False) -> dict:
"""采集指定交易日数据并入库。
Args:
trade_date: YYYY-MM-DD
dry_run: True 只打印不写库(用于验证)
Returns:
{"core_count": int, "theme_count": int, "skipped": bool}
"""
if _has_collected(trade_date):
print(f"[collector] {trade_date} 已采集,跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 1. 拉取全部题材列表(含领涨股,作为当日全部股票的采样来源)
themes = await fetch_theme_list(1, False)
if not themes:
print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过")
return {"core_count": 0, "theme_count": 0, "skipped": True}
# 2. 核心股前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股)
stock_map: dict[str, dict] = {}
for t in themes:
code = t.get("securityCode")
if not code:
continue
if code not in stock_map or (t.get("f3") or 0) > (stock_map[code].get("f3") or 0):
stock_map[code] = {
"stock_code": code,
"stock_name": t.get("securityName", ""),
"f3": t.get("f3"),
}
core_stocks = sorted(
stock_map.values(), key=lambda x: -(x["f3"] or 0)
)[:_CORE_STOCK_LIMIT]
# 3. 题材涨幅前20bf3 降序
top_themes = sorted(themes, key=lambda x: -(x.get("bf3") or 0))[:_TOP_THEME_LIMIT]
if dry_run:
print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)}")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
# 4. 入库(事务,UNIQUE 幂等)
conn = get_connection()
try:
for i, s in enumerate(core_stocks, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, rank) VALUES (?,?,?,?,?)",
(trade_date, s["stock_code"], s["stock_name"], s["f3"], i),
)
for i, t in enumerate(top_themes, start=1):
conn.execute(
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
(trade_date, t["themeCode"], t["themeName"], t.get("bf3"), t.get("hotRank"), i),
)
conn.commit()
finally:
conn.close()
print(f"[collector] {trade_date} 已采集:核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)}")
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
"""后台循环:每个交易日 15:00 后自动采集当日数据(幂等)"""
while True:
try:
now = datetime.now(_CST)
if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME:
trade_date = now.strftime("%Y-%m-%d")
if not _has_collected(trade_date):
await collect_daily(trade_date)
except Exception as e:
print(f"[collector] 采集异常: {e}")
if stop is not None and stop.is_set():
break
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
```
> 注:上面代码中 `_CORE_STOCK_LIMIT` 应为 `CORE_STOCK_LIMIT`(变量名一致),下面 Step 2 统一修正。
- [ ] **Step 2: 修正变量名并运行验证脚本**
`collect_daily``[_CORE_STOCK_LIMIT]` 改为 `[CORE_STOCK_LIMIT]`
验证(dry_run 不写库):
```bash
cd backend && ./venv/bin/python -c "
import asyncio, sys
sys.path.insert(0, '.')
from services.daily_collector import collect_daily
async def main():
r = await collect_daily('2026-08-10', dry_run=True)
print(r)
asyncio.run(main())
"
```
Expected: 输出核心股数量(几十只)与题材数 10,`skipped: False`(首次)。
- [ ] **Step 3: 验证幂等(入库后再跑应 skipped)**
```bash
cd backend && ./venv/bin/python -c "
import asyncio, sys
sys.path.insert(0, '.')
from services.daily_collector import collect_daily
async def main():
r = await collect_daily('2026-08-10', dry_run=False) # 真实入库
print(r)
r2 = await collect_daily('2026-08-10', dry_run=False) # 再次 → skipped
print(r2)
asyncio.run(main())
"
```
Expected: 第一次入库,第二次 `skipped: True`
- [ ] **Step 4: 提交**
```bash
git add backend/services/daily_collector.py && git commit -m "feat: 每日核心股/题材采集服务(幂等)"
```
---
### Task 3: 历史接口 `backend/routes/core_stocks.py`
**Files:**
- Create: `backend/routes/core_stocks.py`
- [ ] **Step 1: 新建路由**
```python
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from database import get_connection, dict_from_row
router = APIRouter()
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
def _recent_trade_dates(conn, n: int = 10) -> list[str]:
"""最近 n 个有数据的交易日(升序)"""
rows = conn.execute(
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
(n,),
).fetchall()
return [r["trade_date"] for r in reversed(rows)]
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
async def active_core_stocks():
conn = get_connection()
try:
dates = _recent_trade_dates(conn, 10)
if not dates:
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
# 窗口内出现过且最近一次出现距今天数 <= 10 个交易日
placeholders = ",".join("?" * len(dates))
rows = conn.execute(
f"""SELECT trade_date, stock_code, stock_name, f3 FROM daily_core_stocks
WHERE trade_date IN ({placeholders})
ORDER BY trade_date DESC, rank ASC""",
dates,
).fetchall()
# 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜
from collections import OrderedDict
stock_days: dict[str, dict] = {}
for r in rows:
code = r["stock_code"]
s = stock_days.setdefault(code, {
"stockCode": code,
"stockName": r["stock_name"],
"dailyGains": {},
"appearCount": 0,
"lastAppear": None,
})
s["dailyGains"][r["trade_date"]] = r["f3"]
s["appearCount"] += 1
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
s["lastAppear"] = r["trade_date"]
stocks = list(stock_days.values())
stocks.sort(key=lambda x: (-x["appearCount"], -(x.get("lastAppear") or "")))
return JSONResponse({"dates": dates, "stocks": stocks}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/history", summary="指定交易日核心股(含所属题材)")
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
conn = get_connection()
try:
rows = conn.execute(
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC", (date,)
).fetchall()
items = []
for r in rows:
d = dict_from_row(r)
themes = conn.execute(
"SELECT theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ? AND stock_code = ?",
(date, d["stock_code"]),
).fetchall()
d["themes"] = [dict(t) for t in themes]
items.append(d)
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
```
- [ ] **Step 2: 验证 active 接口(用 Task 2 入库的数据)**
```bash
cd backend && ./venv/bin/python -c "
import sys; sys.path.insert(0, '.')
from routes.core_stocks import active_core_stocks
import asyncio
async def main():
r = await active_core_stocks()
print('dates:', r.body[:200] if hasattr(r,'body') else r)
asyncio.run(main())
"
```
> 上面直接调函数拿的是 JSONResponse,验证方式见 Step 3(直接查库验证逻辑更直观)。
- [ ] **Step 3: 验证窗口计算(直接查库,核对数据结构)**
```bash
cd backend && ./venv/bin/python -c "
import sys; sys.path.insert(0, '.')
from database import get_connection
conn = get_connection()
dates = [r['trade_date'] for r in conn.execute('SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT 10').fetchall()]
print('最近日期:', dates)
rows = conn.execute('SELECT COUNT(*) AS n FROM daily_core_stocks').fetchone()
print('总记录:', rows['n'])
conn.close()
"
```
Expected: 打印最近日期列表(1 个日期)和总记录数(与核心股数一致)。
- [ ] **Step 4: 提交**
```bash
git add backend/routes/core_stocks.py && git commit -m "feat: 核心股历史/活跃接口"
```
---
### Task 4: 挂载路由 + 启动采集任务 `backend/main.py`
**Files:**
- Modify: `backend/main.py`
- [ ] **Step 1: 修改 main.py**
`from routes import ...``core_stocks`,lifespan 里启动采集任务,注册路由:
```python
from routes import stock, collections, shares, sectors, themes, core_stocks
from services.daily_collector import collector_loop
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
task = asyncio.create_task(collector_loop())
try:
yield
finally:
task.cancel()
```
并在 `app.include_router` 区加:
```python
app.include_router(core_stocks.router, prefix="/api/core-stocks")
```
> 注意:需确保 `import asyncio` 在文件顶部。lifespan 里 `yield` 前启动任务,`finally` 里 cancel,符合 FastAPI 生命周期。
- [ ] **Step 2: 语法检查**
```bash
cd backend && ./venv/bin/python -c "import ast; ast.parse(open('main.py').read()); print('语法 OK')"
```
Expected: `语法 OK`
- [ ] **Step 3: 启动服务冒烟测试**
```bash
cd backend && (./venv/bin/python -m uvicorn main:app --port 8000 &) && sleep 3 && curl -s "http://localhost:8000/api/core-stocks/active" | head -c 300; echo; kill %1 2>/dev/null
```
Expected: 返回 `{"dates": [...], "stocks": [...]}` JSON(至少含 Task 2 入库的当日数据)。
- [ ] **Step 4: 提交**
```bash
git add backend/main.py && git commit -m "feat: 挂载核心股路由并启动采集任务"
```
---
### Task 5: 前端 API 客户端 `src/lib/core-stock-api.ts`
**Files:**
- Create: `src/lib/core-stock-api.ts`
- [ ] **Step 1: 新建 API 客户端**
```typescript
// 核心股历史数据获取工具
import { getApiBaseUrl } from "@/lib/api-client";
export interface ActiveCoreStock {
stockCode: string;
stockName: string;
dailyGains: Record<string, number>; // 日期 -> 当日涨幅
appearCount: number;
lastAppear: string | null;
}
export interface ActiveCoreStocksResponse {
dates: string[];
stocks: ActiveCoreStock[];
}
export interface CoreStockHistoryItem {
id: number;
trade_date: string;
stock_code: string;
stock_name: string;
f3: number | null;
cover_count: number | null;
rank: number;
themes: { theme_code: string; theme_name: string }[];
}
export interface CoreStockHistoryResponse {
date: string;
items: CoreStockHistoryItem[];
}
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
const baseUrl = getApiBaseUrl();
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
if (!resp.ok) return { dates: [], stocks: [] };
return resp.json();
}
/** 获取指定交易日核心股(含所属题材) */
export async function fetchCoreStockHistory(date: string): Promise<CoreStockHistoryResponse | null> {
const baseUrl = getApiBaseUrl();
const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
return resp.json();
}
```
- [ ] **Step 2: 提交**
```bash
git add src/lib/core-stock-api.ts && git commit -m "feat: 核心股历史前端 API 客户端"
```
---
### Task 6: 展示页 `src/routes/core-stocks.tsx`
**Files:**
- Create: `src/routes/core-stocks.tsx`
- [ ] **Step 1: 新建展示页**
```tsx
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
export const Route = createFileRoute("/core-stocks")({
component: CoreStocksPage,
});
/** 格式化涨幅,红涨绿跌 */
function formatGain(v: number | null | undefined): string {
if (v == null) return "·";
const s = v > 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
return s;
}
function CoreStocksPage() {
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["core-stocks", "active"],
queryFn: fetchActiveCoreStocks,
staleTime: 60_000,
retry: false,
});
const dates = data?.dates ?? [];
const stocks = data?.stocks ?? [];
return (
<div className="min-h-screen bg-background">
{/* 顶栏 */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/hot-map" className="hover:opacity-70 transition-opacity">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold"></h1>
</div>
<button
onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button>
</div>
</header>
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
<p className="text-[10px] text-muted-foreground mb-2">
10 · · {stocks.length}
</p>
{isLoading ? (
<div className="animate-pulse rounded-xl bg-muted h-32" />
) : dates.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-16">
</div>
) : (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium whitespace-nowrap"></th>
{dates.map((d) => (
<th key={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
{d.slice(5)}
</th>
))}
<th className="px-2 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{stocks.map((s) => (
<tr key={s.stockCode} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-3 py-1.5 whitespace-nowrap">
<span className="font-medium">{s.stockName}</span>
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
</td>
{dates.map((d) => {
const g = s.dailyGains[d];
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
return (
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
{formatGain(g)}
</td>
);
})}
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
<span className="inline-flex items-center gap-0.5 text-orange-500">
<Flame className="h-3 w-3" />
{s.appearCount}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
```
- [ ] **Step 2: 提交**
```bash
git add src/routes/core-stocks.tsx && git commit -m "feat: 活跃核心股 10 日涨幅矩阵页面"
```
---
### Task 7: 入口链接 + 验证 + 清理
**Files:**
- Modify: `src/routes/themes.tsx`
- Modify: `src/routes/hot-map.tsx`
- Delete(临时): `backend/verify_collector.py`
- [ ] **Step 1: 在题材页顶栏加「核心股」入口**
`src/routes/themes.tsx` 顶栏,在「热点穿透」链接旁加:
```tsx
<Link to="/core-stocks" className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity">
<Flame className="h-3.5 w-3.5" />
</Link>
```
需在 import 区加 `Flame`(若已从 `lucide-react` 引入则复用)。同时 `Link to="/core-stocks"` 需要路由存在(Task 6 已建)。
- [ ] **Step 2: 在热点穿透页顶栏加「核心股」入口**
`src/routes/hot-map.tsx` 顶栏加同类链接(参考 Step 1)。
- [ ] **Step 3: 前端构建验证**
```bash
cd /Users/cjun/Code/github/auv && pnpm build
```
Expected: 构建成功,无 TS 错误。若报 `Link to="/core-stocks"` 类型错误,确认路由文件 `core-stocks.tsx``createFileRoute` 路径与 `to` 一致。
- [ ] **Step 4: 清理临时验证文件**
```bash
rm -f backend/verify_collector.py
```
- [ ] **Step 5: 提交**
```bash
git add src/routes/themes.tsx src/routes/hot-map.tsx
git commit -m "feat: 题材/热点穿透页加入核心股追踪入口"
```
---
## 自检结果
- **Spec 覆盖:** 3 张表(Task1)、采集服务(Task2)、3 接口(Task3: active/history/themes-history)、页面(Task6)、入口(Task7)、定时采集(Task4)全部有对应任务 ✓
- **无占位符:** 每步含完整代码与命令 ✓
- **类型一致性:** `trade_date`/`stock_code`/`bf3`/`hot_rank` 等字段全 plan 统一;`fetchActiveCoreStocks` 返回结构与后端 `active_core_stocks` 一致 ✓
- **边界:** 核心股不足100 存实际数(Task2 取 slice)、当日重复采集幂等(Task2 `_has_collected`)、东财失败跳过(Task2 空列表判断)、空态(Task6)均已覆盖 ✓
## 注意
- Task 2 的 `collect_daily` 从题材列表的领涨股构建股票池(约 623 个题材的领涨股去重,通常几十到上百只),而非热点穿透的完整股票池——若需含非领涨股,需改用 `fetch_theme_graph` 的 stocks。当前实现符合"全部股票按涨幅取前100"的目标口径(题材领涨股 + 去重)。
@@ -0,0 +1,156 @@
# 每日热点核心股 / 题材历史记录 + 活跃核心股滚动表格
日期:2026-08-10
## 1. 目标
为数据分析和发掘积累历史数据,并提供活跃核心股的滚动展示:
1. 每个交易日收盘后,采集**核心股前 100**(当日全部股票按涨幅降序取前 100)及其**所属题材**,保存历史。
2. 每个交易日收盘后,采集**题材(概念)板块涨幅前 20**,保存历史。
3. 新展示页:**活跃核心股 × 最近 10 个 A 股交易日**涨幅矩阵表格;新核心股加入,**超过 10 个 A 股交易日未出现则踢出**。
## 2. 已确认的决策
| 决策点 | 选择 |
|---|---|
| 核心股口径 | 当日全部股票按涨幅(f3)降序取前 100(不限覆盖题材数) |
| 板块口径 | 题材/概念板块,按涨幅(bf3)降序取前 20 |
| 采集触发 | 方案 A:asyncio 后台任务,每日收盘后自动采集 |
| 表格布局 | 股票 × 最近 10 个 A 股交易日列矩阵 |
| 10 日窗口 | 10 个 A 股交易日(跳过节假/周末) |
| 所属题材完整度 | 折中:以热点穿透采样题材为主,东财压力允许时尽力补全 |
## 3. 数据模型(新增 3 张表)
```sql
CREATE TABLE daily_core_stocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL, -- 交易日 YYYY-MM-DD
stock_code TEXT NOT NULL, -- 股票代码
stock_name TEXT NOT NULL, -- 股票名称
f3 REAL, -- 当日涨幅%
cover_count INTEGER, -- 覆盖题材数(采样)
rank INTEGER, -- 当日涨幅排名 1-100
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code)
);
CREATE TABLE daily_core_stock_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
stock_code TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, stock_code, theme_code)
);
CREATE TABLE daily_top_themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
theme_code TEXT NOT NULL,
theme_name TEXT NOT NULL,
bf3 REAL, -- 题材涨幅%
hot_rank INTEGER, -- 热度排名
rank INTEGER, -- 当日板块涨幅排名 1-20
created_at TEXT DEFAULT (datetime('now','localtime')),
UNIQUE(trade_date, theme_code)
);
```
## 4. 采集服务 `backend/services/daily_collector.py`
### 4.1 触发机制(方案 A
- `lifespan` 启动时拉起一个 asyncio 后台任务协程。
- 协程循环(如每 5 分钟检查一次):
- 是否**交易日**(周一至周五,非节假日)。
- 是否**收盘后**(北京时间 > 15:00)。
- 当日数据是否**已采集**(按 `trade_date` 查库,幂等去重)。
- 条件满足 → 触发采集。
### 4.2 采集逻辑
1. **核心股前 100**:调用 `fetch_theme_list(1, False)``fetch_theme_graph` 得到当日全部股票,按 `f3` 降序取前 100。若不足 100 只,存实际数量。
2. **所属题材**:以热点穿透采样的 `themeCodes` 为主存入 `daily_core_stock_themes`
3. **题材前 20**:从 `fetch_theme_list(1, False)``bf3` 降序前 20,连同 `themeName`/`hotRank` 存入 `daily_top_themes`
4. 同一交易日重复触发不重复写入(UNIQUE 去重 + 检查)。
5. 采集失败(东财 403/网络)→ 跳过当日,下轮重试;记录日志。
### 4.3 补全(折中方案)
- 东财压力允许时,对核心股调用个股题材接口尽力补全。
- 优先保证核心股前 100 与题材前 20 的完整性,补全为附加增强,失败不影响主流程。
## 5. 新接口(`backend/routes/`
| 接口 | 说明 |
|---|---|
| `GET /api/core-stocks/active` | 活跃核心股 + 最近 10 日涨幅矩阵 |
| `GET /api/core-stocks/history?date=` | 指定交易日的核心股(含所属题材) |
| `GET /api/themes/history?date=` | 指定交易日的题材前 20 |
### active 接口返回结构
```json
{
"dates": ["2026-08-03", "...", "2026-08-10"],
"stocks": [
{
"stockCode": "601606",
"stockName": "长城军工",
"coverCount": 9,
"lastAppear": "2026-08-10",
"daysSinceLastAppear": 0,
"appearCount": 5,
"dailyGains": { "2026-08-03": 10.0, "2026-08-10": 10.0 }
}
]
}
```
- `dates`:最近 10 个 A 股交易日(升序,最右为最新)。
- `stocks`10 日窗口内出现过的活跃核心股。
- `dailyGains`:日期 → 当日涨幅;未上榜日无该键。
## 6. 新展示页(前端 `/core-stocks`
### 6.1 页面结构
- 顶栏:返回、标题「核心股追踪」、刷新按钮(与 `/hot-map` 一致风格)。
- 表格:**股票 × 最近 10 个 A 股交易日**列矩阵。
- 行:活跃核心股(10 个 A 股交易日内出现过),按 `appearCount` 降序、`lastAppear` 降序排列。
- 列:最近 10 个 A 股交易日,最右为最新。
- 单元格:当日涨幅(红涨绿跌,A 股惯例);未上榜留空(`·`)。
- 每只股票显示累计出现次数、最近上榜日期、所属题材数。
### 6.2 数据获取
- 前端 `useQuery``GET /api/core-stocks/active`
- `staleTime` 与题材页一致(30s 或 60s),盘中可手动刷新。
### 6.3 路由
- 新建 `src/routes/core-stocks.tsx`,路由 `/core-stocks`
- 从题材页 `/themes` 和热点穿透页 `/hot-map` 顶部加入口链接。
## 7. 错误处理与边界
- **东财 403/采集失败**:跳过当日采集,下轮重试;不影响已存历史。
- **当日重复采集**:UNIQUE 约束 + 入库前检查,幂等。
- **核心股不足 100**:存实际数量,不补齐。
- **无历史数据**:部署后开始累积;active 接口在无数据时返回空 `stocks` 与空 `dates`
- **交易日历**:以自然周一到周五判定交易日(不处理法定节假日调休的深度历法),与现有 `_is_trading_time` 一致。
## 8. 测试
- 采集服务:幂等(重复触发不重复写)、去重、失败重试逻辑。
- active 接口:窗口计算、踢出规则(>10 个交易日未出现不返回)、涨幅矩阵正确性。
- 前端页面:空态、有数据态、10 日窗口滚动。
## 9. 范围外(YAGNI
- 不做法定的深度交易日历(节假日调休)。
- 不做个股涨幅的增量更新(历史数据一次性采集,之后不补更)。
- 不做板块成分股的每日存储。
+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:
+60
View File
@@ -0,0 +1,60 @@
// 核心股历史数据获取工具
import { getApiBaseUrl } from "@/lib/api-client";
export interface ActiveCoreStock {
stockCode: string;
stockName: string;
coverCount: number | null; // 覆盖题材数
dailyGains: Record<string, number | null>; // 日期 -> 当日涨幅(可空)
appearCount: number;
lastAppear: string | null;
daysSinceLastAppear: number; // 最近上榜距窗口最新交易日的自然日差
themes: { theme_code: string; theme_name: string }[]; // 所属题材列表
}
export interface ActiveCoreStocksResponse {
dates: string[];
stocks: ActiveCoreStock[];
}
export interface CoreStockHistoryItem {
id: number;
trade_date: string;
stock_code: string;
stock_name: string;
f3: number | null;
cover_count: number | null;
rank: number;
themes: { theme_code: string; theme_name: string }[];
}
export interface CoreStockHistoryResponse {
date: string;
items: CoreStockHistoryItem[];
}
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
const baseUrl = getApiBaseUrl();
try {
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
if (!resp.ok) return { dates: [], stocks: [] };
return resp.json();
} catch (err) {
console.error("[core-stock-api] 获取活跃核心股失败:", err);
return { dates: [], stocks: [] };
}
}
/** 获取指定交易日核心股(含所属题材) */
export async function fetchCoreStockHistory(date: string): Promise<CoreStockHistoryResponse | null> {
const baseUrl = getApiBaseUrl();
try {
const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
return resp.json();
} catch (err) {
console.error("[core-stock-api] 获取核心股历史失败:", err);
return null;
}
}
+8 -48
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 {
@@ -92,7 +97,9 @@ export interface BoardInfo {
* - 8/4/920开头:北交所(BJB
* - 其他:主板
*/
export function getStockBoard(code: string): BoardInfo {
export function getStockBoard(code: string | null | undefined): BoardInfo {
// 题材列表等场景下 securityCode 可能为 null(无领涨股的题材),兜底为主板
if (!code) return { board: "main", label: "", className: "" };
if (code.startsWith("688")) {
return { board: "kcb", label: "科", className: "bg-red-500/10 text-red-500 border-red-500/30" };
}
@@ -425,50 +432,3 @@ export async function fetchFinancialData(code: string, years: number = 5): Promi
}
}
// ---- 板块数据 ----
export interface SectorItem {
code: string;
name: string;
level: number | null;
changePercent: number | null;
changeAmount: number | null;
mainNetInflow: number;
mainNetInflowPercent: number | null;
superLargeInflow: number | null;
superLargeInflowPercent: number | null;
largeInflow: number | null;
largeInflowPercent: number | null;
mediumInflow: number | null;
mediumInflowPercent: number | null;
smallInflow: number | null;
smallInflowPercent: number | null;
turnover: number;
}
export type SectorType = "industry" | "concept";
export interface SectorResponse {
data: SectorItem[];
count: number;
type: SectorType;
}
/**
* 获取东方财富板块列表(按主力净流入排序)
* @param type industry=行业板块, concept=概念板块
*/
export async function fetchSectors(type: SectorType): Promise<SectorItem[]> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/sectors?type=${type}`;
try {
const resp = await fetch(url, { method: "GET" });
if (!resp.ok) return [];
const result: SectorResponse = await resp.json();
return result.data || [];
} catch (err) {
console.error("[stock-api] 获取板块数据失败:", err);
return [];
}
}
+299
View File
@@ -0,0 +1,299 @@
// 题材数据获取工具:通过 Python 后端代理调用东方财富题材接口
import { getApiBaseUrl } from "@/lib/api-client";
/* ── 题材列表 ── */
export interface ThemeItem {
themeCode: string;
themeName: string;
securityName: string | null; // 领涨股名称(无领涨股的题材为 null)
securityCode: string | null; // 领涨股代码(无领涨股的题材为 null)
codeWithSuffix: string;
hotRank: number; // 热度排名
f3: number | null; // 领涨股涨幅
bf3: number | null; // 题材涨幅
hotValue: number; // 热度值
hotValueUpLimit: number; // 热度上限
strengthValue: number | null; // 强度值
fex5: number | null; // 成交额
fex3: number | null;
label: string | null; // 标签(如"超级爆点"
}
export type ThemeSortField = 1 | 3 | 4 | 5; // 1=涨幅 3=强度 4=热度排名 5=成交额
export interface ThemeListResponse {
data: ThemeItem[];
count: number;
sort_field: number;
asc: boolean;
}
/**
* 获取全部题材列表
* @param sortField 1=涨幅 3=强度 4=热度排名 5=成交额
* @param asc true=升序
*/
export async function fetchThemes(sortField: ThemeSortField = 1, asc: boolean = false): Promise<ThemeItem[]> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes?sort_field=${sortField}&asc=${asc}`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return [];
const result: ThemeListResponse = await resp.json();
return result.data || [];
} catch (err) {
console.error("[theme-api] 获取题材列表失败:", err);
return [];
}
}
/* ── 题材详情 ── */
export interface ThemeHotEvent {
newsTitle: string | null;
newsSummary: string | null;
newsMediaName: string | null;
newsPublishTimeFormat: string | null;
newsCode: string | null;
}
export interface ThemeNews {
newsCode: string;
newsTitle: string;
newsMediaName: string;
newsPublishTime: number | null;
}
export interface ThemeBaseInfo {
themeCode: string;
themeName: string;
introduction: string;
explainImgUrl: string | null;
themeLevel: number;
isShowRank: number;
}
export interface ThemeDetail {
baseInfo: ThemeBaseInfo;
hotEvent: ThemeHotEvent | null;
eventHistory: ThemeNews[];
topicId: string | null;
}
/**
* 获取题材详情(简介 + 热点事件 + 相关新闻)
*/
export async function fetchThemeDetail(themeCode: string): Promise<ThemeDetail | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes/${themeCode}/detail`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
const result = await resp.json();
return result.data || null;
} catch (err) {
console.error("[theme-api] 获取题材详情失败:", err);
return null;
}
}
/* ── 题材相关股票 ── */
export interface ThemeStockKeyword {
keywordCode: string;
keyword: string;
introduction: string;
}
export interface ThemeStock {
securityName: string;
securityCode: string;
codeSuffix: string;
f2: number; // 现价
f3: number; // 涨跌幅%
f5: number; // 成交量
f6: number; // 成交额
f8: number; // 换手率%
f20: number; // 总市值
f21: number; // 流通市值
f62: number; // 主力净流入
f100: string; // 所属行业
f265: string; // 板块代码
label: string | null; // 涨停标签
rank: number;
dragonStockLabel: number;
keywordList: ThemeStockKeyword[]; // 入选理由
}
export interface ThemeStatistic {
f3: number | null; // 板块涨幅
f104: number | null; // 上涨家数
f105: number | null; // 下跌家数
f106: number | null; // 平盘家数
fex5: number | null; // 板块成交额
}
export interface ThemeStocksResponse {
data: ThemeStock[];
statistic: ThemeStatistic;
total: number;
}
/**
* 获取题材下全部相关股票
*/
export async function fetchThemeStocks(themeCode: string): Promise<ThemeStocksResponse | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes/${themeCode}/stocks`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
return await resp.json();
} catch (err) {
console.error("[theme-api] 获取题材股票失败:", err);
return null;
}
}
/* ── 题材相关新闻(分页) ── */
export interface ThemeNewsItem {
newsCode: string;
newsTitle: string;
newsMediaName: string;
showDateTime: number | null;
showDateTimeFormat: string | null;
commentCount: number;
themeCode: string;
themeName: string;
}
export interface ThemeNewsResponse {
total: number;
maxEuTime: string;
list: ThemeNewsItem[];
}
/**
* 获取题材相关新闻(分页,maxEuTime 为翻页游标)
*/
export async function fetchThemeNews(
themeCode: string,
pageNum: number = 1,
pageSize: number = 10,
maxEuTime: string = "",
): Promise<ThemeNewsResponse | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes/${themeCode}/news?page_num=${pageNum}&page_size=${pageSize}&max_eu_time=${encodeURIComponent(maxEuTime)}`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
const result = await resp.json();
return result.data || null;
} catch (err) {
console.error("[theme-api] 获取题材新闻失败:", err);
return null;
}
}
/* ── 单题材实时行情(强度/热度/涨幅) ── */
export interface ThemeQuote {
strengthValue: number | null;
hotValueUpLimit: number;
hotValue: number;
f3: number | null;
}
/**
* 获取单题材实时行情
*/
export async function fetchThemeQuote(themeCode: string): Promise<ThemeQuote | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes/${themeCode}/quote`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
const result = await resp.json();
return result.data || null;
} catch (err) {
console.error("[theme-api] 获取题材行情失败:", err);
return null;
}
}
/* ── 热点穿透:题材-股票 网状关系图 ── */
export interface GraphTheme {
themeCode: string;
themeName: string;
stockCount: number; // 题材内股票数
bf3: number | null; // 题材涨幅
}
export interface GraphStock {
securityCode: string;
securityName: string;
coverCount: number; // 覆盖题材数(穿透强度)
f3: number | null; // 涨幅
f2: number | null; // 现价
f62: number | null; // 主力净流入
f100: string; // 行业
themeCodes: string[]; // 所属题材代码
}
export interface GraphStats {
themeCount: number;
stockCount: number;
coreCount: number; // 覆盖≥2 的核心股数
maxCover: number; // 最大覆盖题材数
}
export interface ThemeGraph {
themes: GraphTheme[];
stocks: GraphStock[];
stats: GraphStats;
}
/**
* 从股票覆盖题材重建关系边(后端不再下发 edges,体积减半以上)
* 返回 d3-force 可直接使用的 source/target 节点 id"t:题材code" / "s:股票code"
*/
export function buildEdgesFromStocks(stocks: GraphStock[]): { source: string; target: string }[] {
const edges: { source: string; target: string }[] = [];
for (const s of stocks) {
const target = `s:${s.securityCode}`;
for (const tc of s.themeCodes) edges.push({ source: `t:${tc}`, target });
}
return edges;
}
/**
* 获取热点穿透图数据(题材-股票 M:N 网状关系)
* @param sortField 1=涨幅 4=热度
* @param top 题材数量
* @param limit 下发的股票节点上限(按穿透度取前 N 只)
*/
export async function fetchThemeGraph(
sortField: 1 | 4 = 1,
top: number = 30,
limit: number = 1000,
): Promise<ThemeGraph | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/themes/graph?sort_field=${sortField}&top=${top}&limit=${limit}`;
try {
const resp = await fetch(url, { method: "GET", cache: "no-store" });
if (!resp.ok) return null;
return await resp.json();
} catch (err) {
console.error("[theme-api] 获取热点穿透图数据失败:", err);
return null;
}
}
+5 -2
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>
);
+93 -17
View File
@@ -9,14 +9,27 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SectorsRouteImport } from './routes/sectors'
import { Route as ThemesRouteImport } from './routes/themes'
import { Route as HotMapRouteImport } from './routes/hot-map'
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
import { Route as StockCodeRouteImport } from './routes/stock.$code'
import { Route as ShareCodeRouteImport } from './routes/share.$code'
const SectorsRoute = SectorsRouteImport.update({
id: '/sectors',
path: '/sectors',
const ThemesRoute = ThemesRouteImport.update({
id: '/themes',
path: '/themes',
getParentRoute: () => rootRouteImport,
} as any)
const HotMapRoute = HotMapRouteImport.update({
id: '/hot-map',
path: '/hot-map',
getParentRoute: () => rootRouteImport,
} as any)
const CoreStocksRoute = CoreStocksRouteImport.update({
id: '/core-stocks',
path: '/core-stocks',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
@@ -24,6 +37,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ThemeCodeRoute = ThemeCodeRouteImport.update({
id: '/theme/$code',
path: '/theme/$code',
getParentRoute: () => rootRouteImport,
} as any)
const StockCodeRoute = StockCodeRouteImport.update({
id: '/stock/$code',
path: '/stock/$code',
@@ -37,45 +55,93 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/sectors': typeof SectorsRoute
'/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/sectors': typeof SectorsRoute
'/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/sectors': typeof SectorsRoute
'/core-stocks': typeof CoreStocksRoute
'/hot-map': typeof HotMapRoute
'/themes': typeof ThemesRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/sectors' | '/share/$code' | '/stock/$code'
fullPaths:
| '/'
| '/core-stocks'
| '/hot-map'
| '/themes'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/sectors' | '/share/$code' | '/stock/$code'
id: '__root__' | '/' | '/sectors' | '/share/$code' | '/stock/$code'
to:
| '/'
| '/core-stocks'
| '/hot-map'
| '/themes'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
id:
| '__root__'
| '/'
| '/core-stocks'
| '/hot-map'
| '/themes'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SectorsRoute: typeof SectorsRoute
CoreStocksRoute: typeof CoreStocksRoute
HotMapRoute: typeof HotMapRoute
ThemesRoute: typeof ThemesRoute
ShareCodeRoute: typeof ShareCodeRoute
StockCodeRoute: typeof StockCodeRoute
ThemeCodeRoute: typeof ThemeCodeRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/sectors': {
id: '/sectors'
path: '/sectors'
fullPath: '/sectors'
preLoaderRoute: typeof SectorsRouteImport
'/themes': {
id: '/themes'
path: '/themes'
fullPath: '/themes'
preLoaderRoute: typeof ThemesRouteImport
parentRoute: typeof rootRouteImport
}
'/hot-map': {
id: '/hot-map'
path: '/hot-map'
fullPath: '/hot-map'
preLoaderRoute: typeof HotMapRouteImport
parentRoute: typeof rootRouteImport
}
'/core-stocks': {
id: '/core-stocks'
path: '/core-stocks'
fullPath: '/core-stocks'
preLoaderRoute: typeof CoreStocksRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
@@ -85,6 +151,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/theme/$code': {
id: '/theme/$code'
path: '/theme/$code'
fullPath: '/theme/$code'
preLoaderRoute: typeof ThemeCodeRouteImport
parentRoute: typeof rootRouteImport
}
'/stock/$code': {
id: '/stock/$code'
path: '/stock/$code'
@@ -104,9 +177,12 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SectorsRoute: SectorsRoute,
CoreStocksRoute: CoreStocksRoute,
HotMapRoute: HotMapRoute,
ThemesRoute: ThemesRoute,
ShareCodeRoute: ShareCodeRoute,
StockCodeRoute: StockCodeRoute,
ThemeCodeRoute: ThemeCodeRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+1 -1
View File
@@ -20,5 +20,5 @@ export const getRouter = () => {
defaultPreloadStaleTime: 0,
});
return router;
return { router, queryClient };
};
+143
View File
@@ -0,0 +1,143 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { Fragment } from "react";
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
export const Route = createFileRoute("/core-stocks")({
component: CoreStocksPage,
});
/** 格式化涨幅,红涨绿跌 */
function formatGain(v: number | null | undefined): string {
if (v == null) return "·";
const s = v >= 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
return s;
}
function CoreStocksPage() {
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["core-stocks", "active"],
queryFn: fetchActiveCoreStocks,
staleTime: 60_000,
retry: false,
});
const dates = data?.dates ?? [];
const stocks = data?.stocks ?? [];
return (
<div className="min-h-screen bg-background">
{/* 顶栏 */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/hot-map" className="hover:opacity-70 transition-opacity" aria-label="返回">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold"></h1>
</div>
<button
onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button>
</div>
</header>
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
<p className="text-[10px] text-muted-foreground mb-2">
10 · · {stocks.length}
</p>
{isLoading ? (
<div className="animate-pulse rounded-xl bg-muted h-32" />
) : dates.length === 0 || stocks.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-16">
</div>
) : (
<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th scope="col" className="px-3 py-2 text-left font-medium whitespace-nowrap"></th>
{dates.map((d) => (
<th key={d} scope="col" title={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
{d.slice(5)}
</th>
))}
<th scope="col" className="px-2 py-2 text-right font-medium" title="覆盖题材数"></th>
<th scope="col" className="px-2 py-2 text-right font-medium whitespace-nowrap"></th>
<th scope="col" className="px-2 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{stocks.map((s) => (
<Fragment key={s.stockCode}>
<tr className="border-b last:border-0 hover:bg-muted/30">
<td className="px-3 py-1.5 whitespace-nowrap">
<Link
to="/stock/$code"
params={{ code: s.stockCode }}
className="font-medium hover:text-primary hover:underline"
>
{s.stockName}
</Link>
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
</td>
{dates.map((d) => {
const g = s.dailyGains[d];
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
return (
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
{formatGain(g)}
</td>
);
})}
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
{s.coverCount ?? "·"}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
{s.lastAppear ? s.lastAppear.slice(5) : "·"}
</td>
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
<span className="inline-flex items-center gap-0.5 text-orange-500">
<Flame className="h-3 w-3" />
{s.appearCount}
</span>
</td>
</tr>
<tr className="border-b bg-muted/20">
<td colSpan={dates.length + 4} className="px-3 py-2">
<div className="text-[11px] text-muted-foreground mb-1"></div>
{s.themes && s.themes.length > 0 ? (
<div className="flex flex-wrap gap-1">
{s.themes.map((t) => (
<Link
key={t.theme_code}
to="/theme/$code"
params={{ code: t.theme_code }}
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] hover:bg-primary/20 transition-colors"
>
{t.theme_name}
</Link>
))}
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
</tr>
</Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+18 -10
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,20 @@ function Index() {
A股走势追踪
</h1>
<p className="text-sm md:text-base text-muted-foreground"></p>
<Link to="/sectors">
<Button variant="outline" size="sm" className="mt-3 gap-1.5 text-xs">
<TrendingUp className="h-3.5 w-3.5" />
</Button>
</Link>
<div className="mt-3 flex items-center justify-center gap-2">
<Link to="/themes">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<Flame className="h-3.5 w-3.5" />
</Button>
</Link>
<Link to="/hot-map">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<Network className="h-3.5 w-3.5" />
穿
</Button>
</Link>
</div>
</div>
<Card className="mb-6 md:mb-8 shadow-lg">
@@ -517,7 +525,7 @@ function CollectionCard({
<p className="text-sm text-muted-foreground text-center py-4"></p>
) : (
<div className="space-y-1">
{stocks.slice(0, 10).map((stock) => (
{stocks.slice(0, 20).map((stock) => (
<StockRowItem
key={stock.id}
stock={stock}
@@ -526,8 +534,8 @@ function CollectionCard({
swipeResetKey={swipeResetKey}
/>
))}
{stocks.length > 10 && (
<p className="text-xs text-muted-foreground text-center pt-1"> {stocks.length - 10} ...</p>
{stocks.length > 20 && (
<p className="text-xs text-muted-foreground text-center pt-1"> {stocks.length - 20} ...</p>
)}
</div>
)}
-231
View File
@@ -1,231 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect, useMemo } from "react";
import { fetchSectors, type SectorItem, type SectorType } from "@/lib/stock-api";
import { Card, CardContent } from "@/components/ui/card";
import { ArrowLeft, TrendingUp, TrendingDown, RefreshCw } from "lucide-react";
import { Link } from "@tanstack/react-router";
export const Route = createFileRoute("/sectors")({
component: SectorsPage,
});
const TABS: { key: SectorType; label: string }[] = [
{ key: "industry", label: "行业板块" },
{ key: "concept", label: "概念板块" },
];
type SortMode = "mainNetInflow" | "changePercent";
function SectorsPage() {
const [tab, setTab] = useState<SectorType>("industry");
const [data, setData] = useState<SectorItem[]>([]);
const [loading, setLoading] = useState(true);
const [sort, setSort] = useState<SortMode>("mainNetInflow");
const loadData = (t: SectorType) => {
setLoading(true);
fetchSectors(t).then((items) => {
setData(items);
setLoading(false);
});
};
useEffect(() => {
loadData(tab);
}, [tab]);
const sorted = useMemo(() => {
return [...data].sort((a, b) => {
if (sort === "mainNetInflow") return b.mainNetInflow - a.mainNetInflow;
return (b.changePercent ?? 0) - (a.changePercent ?? 0);
});
}, [data, sort]);
return (
<div className="min-h-screen bg-background">
{/* 顶栏 */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/" className="hover:opacity-70 transition-opacity">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold"></h1>
</div>
<button
onClick={() => loadData(tab)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
</header>
{/* Tab + Sorting 切换 */}
<div className="max-w-5xl mx-auto px-4 mt-4 flex items-center gap-2">
<div className="flex gap-1 bg-muted rounded-lg p-1 flex-1">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
tab === t.key
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t.label}
</button>
))}
</div>
<div className="flex gap-0.5 text-xs border rounded overflow-hidden shrink-0">
<button onClick={() => setSort("mainNetInflow")}
className={`px-2 py-1 transition-colors ${
sort === "mainNetInflow"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
<button onClick={() => setSort("changePercent")}
className={`px-2 py-1 transition-colors ${
sort === "changePercent"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
</div>
</div>
{/* 数据信息 */}
<div className="max-w-5xl mx-auto px-4 mt-2">
<p className="text-[10px] text-muted-foreground">
{sorted.length} · {sort === "mainNetInflow" ? "主力净流入" : "涨跌幅"}
</p>
</div>
{/* 卡片网格 */}
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{Array.from({ length: 20 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-xl bg-muted h-32" />
))}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{sorted.map((item) => (
<SectorBlock key={item.code} item={item} />
))}
</div>
)}
</div>
</div>
);
}
function formatInflow(val: number | null | undefined): string {
if (val == null) return "--";
const abs = Math.abs(val);
if (abs >= 1e8) return (val / 1e8).toFixed(2) + "亿";
if (abs >= 1e4) return (val / 1e4).toFixed(0) + "万";
return val.toFixed(0);
}
function FundFlowBar({ value, maxAbs }: { value: number | null | undefined; maxAbs: number }) {
if (value == null) return null;
const pct = maxAbs > 0 ? (value / maxAbs) * 100 : 0;
const isPos = value >= 0;
return (
<div className="flex items-center gap-1">
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
isPos ? "bg-red-500/60" : "bg-green-500/60"
}`}
style={{ width: `${Math.min(Math.abs(pct), 100)}%`, marginLeft: isPos ? "50%" : undefined }}
/>
</div>
<span className={`text-[10px] font-medium tabular-nums w-14 text-right ${
isPos ? "text-red-500" : "text-green-500"
}`}>
{formatInflow(value)}
</span>
</div>
);
}
function SectorBlock({ item }: { item: SectorItem }) {
const inflow = item.mainNetInflow;
const isPositive = inflow >= 0;
const change = item.changePercent;
const maxAbs = Math.max(
Math.abs(item.mainNetInflow),
Math.abs(item.superLargeInflow ?? 0),
Math.abs(item.largeInflow ?? 0),
Math.abs(item.mediumInflow ?? 0),
Math.abs(item.smallInflow ?? 0),
1
);
return (
<Card className="rounded-xl hover:shadow-md transition-shadow">
<CardContent className="p-3 space-y-2">
{/* 板块名称 */}
<div className="flex items-center justify-between gap-1">
<p className="text-sm font-medium truncate" title={item.name}>
{item.name}
</p>
{item.code && (
<span className="shrink-0 text-[9px] text-muted-foreground/60 font-mono">
{item.code.replace("BK", "")}
</span>
)}
</div>
{/* 涨跌幅 */}
<div className="flex items-center justify-between">
{change != null ? (
<span
className={`inline-flex items-center gap-0.5 text-xs font-semibold ${
change >= 0 ? "text-red-500" : "text-green-500"
}`}
>
{change >= 0 ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
{change >= 0 ? "+" : ""}
{change.toFixed(2)}%
</span>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
<span className="text-[10px] text-muted-foreground">
{formatInflow(item.turnover)}
</span>
</div>
{/* 主力净流入 */}
<div className="pt-1.5 border-t border-border/40">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className={`text-xs font-bold tabular-nums ${
isPositive ? "text-red-500" : "text-green-500"
}`}>
{isPositive ? "+" : ""}{formatInflow(inflow)}
</span>
</div>
{/* 资金流向明细条 */}
<div className="space-y-0.5">
<FundFlowBar value={item.superLargeInflow} maxAbs={maxAbs} />
<FundFlowBar value={item.largeInflow} maxAbs={maxAbs} />
<FundFlowBar value={item.mediumInflow} maxAbs={maxAbs} />
<FundFlowBar value={item.smallInflow} maxAbs={maxAbs} />
</div>
</div>
</CardContent>
</Card>
);
}
+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">
+430
View File
@@ -0,0 +1,430 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
fetchThemeDetail,
fetchThemeNews,
fetchThemeQuote,
fetchThemeStocks,
type ThemeStock,
type ThemeNewsItem,
} from "@/lib/theme-api";
import { getStockBoard } from "@/lib/stock-api";
import { formatMoney } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import {
ArrowLeft,
RefreshCw,
TrendingUp,
TrendingDown,
Flame,
Newspaper,
ChevronDown,
ChevronUp,
Info,
} from "lucide-react";
export const Route = createFileRoute("/theme/$code")({
component: ThemeDetailPage,
});
function ThemeDetailPage() {
const { code } = Route.useParams();
const detailQ = useQuery({
queryKey: ["themeDetail", code],
queryFn: () => fetchThemeDetail(code),
staleTime: 60_000,
retry: false,
});
const stocksQ = useQuery({
queryKey: ["themeStocks", code],
queryFn: () => fetchThemeStocks(code),
staleTime: 30_000,
retry: false,
});
// 相关新闻:pageNum 偏移分页(东财接口 maxEuTime 是增量游标,翻页靠 pageNum 递增)
const [newsPage, setNewsPage] = useState(1);
const [newsItems, setNewsItems] = useState<ThemeNewsItem[]>([]);
const newsQ = useQuery({
queryKey: ["themeNews", code, newsPage],
queryFn: () => fetchThemeNews(code, newsPage, 10),
staleTime: 60_000,
retry: false,
});
const quoteQ = useQuery({
queryKey: ["themeQuote", code],
queryFn: () => fetchThemeQuote(code),
staleTime: 30_000,
retry: false,
});
// 分页追加:首页重置列表,翻页拼接
useEffect(() => {
if (!newsQ.data?.list) return;
setNewsItems((prev) => (newsPage === 1 ? newsQ.data!.list : [...prev, ...newsQ.data!.list]));
}, [newsQ.data, newsPage]);
const isLoading = detailQ.isLoading || stocksQ.isLoading;
const isError = detailQ.isError || stocksQ.isError;
const isFetching = detailQ.isFetching || stocksQ.isFetching;
const detail = detailQ.data;
const stocks = stocksQ.data?.data ?? [];
const statistic = stocksQ.data?.statistic;
const total = stocksQ.data?.total ?? 0;
const refresh = () => {
detailQ.refetch();
stocksQ.refetch();
quoteQ.refetch();
setNewsPage(1);
newsQ.refetch();
};
const baseInfo = detail?.baseInfo;
const hotEvent = detail?.hotEvent;
return (
<div className="min-h-screen bg-background">
{/* ── 顶栏 ── */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="max-w-3xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<Link to="/themes" className="hover:opacity-70 transition-opacity shrink-0">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold truncate">{baseInfo?.themeName ?? "题材详情"}</h1>
</div>
<button
onClick={refresh}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button>
</div>
</header>
<div className="max-w-3xl mx-auto px-4 py-4 pb-10 space-y-4">
{isLoading ? (
<div className="space-y-4">
<div className="animate-pulse rounded-xl bg-muted h-32" />
<div className="animate-pulse rounded-xl bg-muted h-24" />
<div className="animate-pulse rounded-xl bg-muted h-64" />
</div>
) : isError ? (
<div className="flex flex-col items-center gap-3 py-20">
<p className="text-sm text-muted-foreground"></p>
<button onClick={refresh} className="text-xs text-primary hover:underline">
</button>
</div>
) : (
<>
{/* ── 题材简介 ── */}
{baseInfo?.introduction && (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<Info className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold"></h2>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{baseInfo.introduction}
</p>
</CardContent>
</Card>
)}
{/* ── 热点事件 ── */}
{hotEvent?.newsTitle && (
<Card className="border-orange-500/30">
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<Flame className="h-4 w-4 text-orange-500" />
<h2 className="text-sm font-semibold"></h2>
{hotEvent.newsMediaName && (
<span className="text-[10px] text-muted-foreground ml-auto shrink-0">
{hotEvent.newsMediaName}
{hotEvent.newsPublishTimeFormat ? ` · ${hotEvent.newsPublishTimeFormat}` : ""}
</span>
)}
</div>
<p className="text-sm font-medium leading-snug">{hotEvent.newsTitle}</p>
{hotEvent.newsSummary && (
<p className="text-xs text-muted-foreground leading-relaxed mt-1.5 line-clamp-3">
{hotEvent.newsSummary}
</p>
)}
</CardContent>
</Card>
)}
{/* ── 板块统计 ── */}
<StatBar
f3={statistic?.f3}
up={statistic?.f104}
down={statistic?.f105}
flat={statistic?.f106}
fex5={statistic?.fex5}
total={total}
strength={quoteQ.data?.strengthValue ?? null}
hotValue={quoteQ.data?.hotValue ?? 0}
hotValueUpLimit={quoteQ.data?.hotValueUpLimit ?? 0}
/>
{/* ── 相关新闻(分页加载) ── */}
{newsItems.length > 0 && (
<NewsList
items={newsItems}
total={newsQ.data?.total ?? 0}
loadingMore={newsQ.isFetching && newsPage > 1}
onLoadMore={() => setNewsPage((p) => p + 1)}
/>
)}
{/* ── 相关股票 ── */}
<div>
<div className="flex items-center justify-between mb-2 px-1">
<h2 className="text-sm font-semibold"></h2>
<span className="text-[10px] text-muted-foreground"> {total} </span>
</div>
{stocks.length === 0 ? (
<Card>
<CardContent className="p-6 text-center text-sm text-muted-foreground">
</CardContent>
</Card>
) : (
<div className="space-y-2">
{stocks.map((s) => (
<ThemeStockRow key={s.securityCode} stock={s} />
))}
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
/* ============================================================
板块统计条
============================================================ */
function StatBar({
f3,
up,
down,
flat,
fex5,
total,
strength,
hotValue,
hotValueUpLimit,
}: {
f3: number | null | undefined;
up: number | null | undefined;
down: number | null | undefined;
flat: number | null | undefined;
fex5: number | null | undefined;
total: number;
strength: number | null;
hotValue: number;
hotValueUpLimit: number;
}) {
const isPos = (f3 ?? 0) >= 0;
const hotPct = hotValueUpLimit > 0 ? Math.min((hotValue / hotValueUpLimit) * 100, 100) : 0;
const showQuote = strength != null || hotValueUpLimit > 0;
return (
<Card>
<CardContent className="p-3">
<div className="grid grid-cols-4 divide-x divide-border/50 text-center">
<div>
<p className="text-[10px] text-muted-foreground"></p>
<p className={`text-sm font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
{f3 != null ? `${isPos ? "+" : ""}${f3.toFixed(2)}%` : "--"}
</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground"></p>
<p className="text-sm font-bold tabular-nums text-red-500">{up ?? "--"}</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground"></p>
<p className="text-sm font-bold tabular-nums text-green-500">{down ?? "--"}</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground"></p>
<p className="text-xs font-semibold tabular-nums">{fex5 != null ? formatMoney(fex5) : "--"}</p>
</div>
</div>
{(flat != null && flat > 0) && (
<p className="text-[10px] text-muted-foreground text-center mt-1.5">
{flat}
</p>
)}
{/* 强度 + 热度(来自单题材实时行情接口) */}
{showQuote && (
<div className="mt-2 pt-2 border-t border-border/40 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
{strength != null && (
<span className="inline-flex items-center gap-1">
<b className="font-bold text-foreground tabular-nums">{strength}</b>
</span>
)}
{hotValueUpLimit > 0 && (
<span className="inline-flex items-center gap-1.5">
<Flame className="h-3 w-3 text-orange-500" />
<span className="w-16 h-1 rounded-full bg-muted overflow-hidden">
<span
className="block h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
style={{ width: `${Math.max(hotPct, 2)}%` }}
/>
</span>
{hotValue}/{hotValueUpLimit}
</span>
)}
</div>
)}
</CardContent>
</Card>
);
}
/* ============================================================
相关新闻(分页加载)
============================================================ */
function NewsList({
items,
total,
loadingMore,
onLoadMore,
}: {
items: ThemeNewsItem[];
total: number;
loadingMore: boolean;
onLoadMore: () => void;
}) {
const hasMore = items.length < total;
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<Newspaper className="h-4 w-4 text-primary" />
<h2 className="text-sm font-semibold"></h2>
<span className="text-[10px] text-muted-foreground ml-auto"> {total} </span>
</div>
<div className="space-y-2.5">
{items.map((n, idx) => (
<div key={idx} className="space-y-0.5">
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
<p className="text-[10px] text-muted-foreground">
{n.newsMediaName}
{n.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""}
{n.commentCount > 0 && <span className="ml-1">· {n.commentCount} </span>}
</p>
</div>
))}
</div>
{hasMore && (
<button
onClick={onLoadMore}
disabled={loadingMore}
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5 disabled:opacity-50"
>
{loadingMore ? "加载中…" : "加载更多"}
{!loadingMore && <ChevronDown className="h-3 w-3" />}
</button>
)}
</CardContent>
</Card>
);
}
/* ============================================================
相关股票行
============================================================ */
function ThemeStockRow({ stock }: { stock: ThemeStock }) {
const [showReason, setShowReason] = useState(true); // 入选理由默认展开
const board = getStockBoard(stock.securityCode);
const isPos = stock.f3 >= 0;
const reasons = stock.keywordList ?? [];
// 换手率:接口返回放大 100 倍的值(如 3733 = 37.33%
const turnoverRate = stock.f8 > 100 ? stock.f8 / 100 : stock.f8;
return (
<Link to="/stock/$code" params={{ code: stock.securityCode }} className="block">
<Card className="rounded-xl hover:shadow-md transition-shadow">
<CardContent className="p-3 space-y-1.5">
{/* 名称 + 现价 + 涨幅 */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<p className="text-sm font-medium truncate">{stock.securityName}</p>
{board.label && (
<span
className={`inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold leading-none shrink-0 ${board.className}`}
>
{board.label}
</span>
)}
{stock.label && (
<span className="shrink-0 text-[9px] font-medium text-orange-500 bg-orange-500/10 border border-orange-500/30 rounded px-1 py-0.5">
{stock.label}
</span>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="text-right">
<p className="text-[10px] text-muted-foreground"></p>
<p className="text-sm font-semibold tabular-nums">{stock.f2.toFixed(2)}</p>
</div>
<div className="text-right min-w-[56px]">
<p className="text-[10px] text-muted-foreground"></p>
<p className={`text-sm font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
{isPos ? "+" : ""}
{stock.f3.toFixed(2)}%
</p>
</div>
</div>
</div>
{/* 行业 + 换手 + 主力 + 成交额 */}
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
{stock.f100 && (
<span className="truncate bg-muted rounded px-1.5 py-0.5 text-[10px]">{stock.f100}</span>
)}
<span className="shrink-0 tabular-nums"> {turnoverRate.toFixed(2)}%</span>
<span className="shrink-0 tabular-nums"> {formatMoney(stock.f62)}</span>
<span className="shrink-0 tabular-nums ml-auto"> {formatMoney(stock.f6)}</span>
</div>
{/* 入选理由 */}
{reasons.length > 0 && (
<div className="border-t border-border/40 pt-1.5">
<button
onClick={(e) => {
e.preventDefault();
setShowReason((v) => !v);
}}
className="text-[10px] text-primary hover:underline inline-flex items-center gap-0.5"
>
{showReason ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
{showReason && (
<p className="text-[11px] text-muted-foreground leading-relaxed mt-1">
{reasons.map((r) => r.introduction).filter(Boolean).join(" ")}
</p>
)}
</div>
)}
</CardContent>
</Card>
</Link>
);
}
+251
View File
@@ -0,0 +1,251 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchThemes, type ThemeItem, type ThemeSortField } from "@/lib/theme-api";
import { getStockBoard } from "@/lib/stock-api";
import { formatMoney } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import {
ArrowLeft,
RefreshCw,
ArrowDown,
ArrowUp,
TrendingUp,
TrendingDown,
Flame,
Network,
} from "lucide-react";
export const Route = createFileRoute("/themes")({
component: ThemesPage,
});
/* ============================================================
排序维度(sortField 与后端/东方财富对齐)
============================================================ */
const SORTS: { key: ThemeSortField; label: string }[] = [
{ key: 1, label: "涨幅" },
{ key: 3, label: "强度" },
{ key: 4, label: "热度" },
{ key: 5, label: "成交额" },
];
function ThemesPage() {
const [sortField, setSortField] = useState<ThemeSortField>(1);
const [asc, setAsc] = useState(false); // 默认降序
const { data: themes, isLoading, isFetching, isError, refetch } = useQuery({
queryKey: ["themes", sortField, asc],
queryFn: () => fetchThemes(sortField, asc),
staleTime: 30_000,
retry: false,
});
const data = themes ?? [];
const toggleSort = (key: ThemeSortField) => {
if (key === sortField) {
setAsc((v) => !v);
} else {
setSortField(key);
setAsc(false); // 切新维度默认降序
}
};
return (
<div className="min-h-screen bg-background">
{/* ── 顶栏 ── */}
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/" className="hover:opacity-70 transition-opacity">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-base font-semibold"></h1>
</div>
<div className="flex items-center gap-3">
<Link
to="/hot-map"
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
>
<Network className="h-3.5 w-3.5" />
穿
</Link>
<Link
to="/core-stocks"
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
>
<Flame className="h-3.5 w-3.5" />
</Link>
<button
onClick={() => refetch()}
className="text-muted-foreground hover:text-foreground transition-colors"
title="刷新"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
</button>
</div>
</div>
</header>
{/* ── 排序切换 + 统计 ── */}
<div className="max-w-5xl mx-auto px-4 mt-3 flex items-center justify-between">
<p className="text-[10px] text-muted-foreground">
{data.length}
{isFetching && (
<span className="ml-1 text-[10px] text-muted-foreground/60">· </span>
)}
</p>
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{SORTS.map((s) => (
<button
key={s.key}
onClick={() => toggleSort(s.key)}
className={`px-2.5 py-1 flex items-center gap-0.5 transition-colors ${
sortField === s.key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{s.label}
{sortField === s.key &&
(asc ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</button>
))}
</div>
</div>
{/* ── 内容区 ── */}
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Array.from({ length: 18 }).map((_, i) => (
<div key={i} className="animate-pulse rounded-xl bg-muted h-32" />
))}
</div>
) : isError ? (
<div className="flex flex-col items-center gap-3 py-20">
<p className="text-sm text-muted-foreground"></p>
<button onClick={() => refetch()} className="text-xs text-primary hover:underline">
</button>
</div>
) : data.length === 0 ? (
<div className="text-center py-20 text-sm text-muted-foreground"></div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{data.map((item) => (
<ThemeCard key={item.themeCode} item={item} />
))}
</div>
)}
</div>
</div>
);
}
/* ============================================================
数值格式化
============================================================ */
function fmt(val: number | null | undefined, digits = 2): string {
if (val == null) return "--";
return val.toFixed(digits);
}
/* ============================================================
题材卡片
============================================================ */
function ThemeCard({ item }: { item: ThemeItem }) {
const change = item.bf3;
const hotPct =
item.hotValueUpLimit > 0 ? Math.min((item.hotValue / item.hotValueUpLimit) * 100, 100) : 0;
const stockBoard = getStockBoard(item.securityCode);
return (
<Link to="/theme/$code" params={{ code: item.themeCode }} className="block">
<Card className="rounded-xl hover:shadow-md transition-shadow h-full">
<CardContent className="p-3 space-y-2">
{/* 题材名 + 领涨标签 */}
<div className="flex items-center justify-between gap-1">
<p className="text-sm font-medium truncate" title={item.themeName}>
{item.themeName}
</p>
{item.label && (
<span className="shrink-0 text-[9px] font-medium text-orange-500 bg-orange-500/10 border border-orange-500/30 rounded px-1 py-0.5">
{item.label}
</span>
)}
</div>
{/* 热度进度条 */}
<div className="flex items-center gap-1">
<Flame className="h-3 w-3 text-orange-500 shrink-0" />
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
style={{ width: `${Math.max(hotPct, 2)}%` }}
/>
</div>
<span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
{item.hotValue}/{item.hotValueUpLimit}
</span>
</div>
{/* 涨幅 + 成交额 */}
<div className="flex items-center justify-between">
{change != null ? (
<span
className={`inline-flex items-center gap-0.5 text-xs font-semibold ${
change >= 0 ? "text-red-500" : "text-green-500"
}`}
>
{change >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{change >= 0 ? "+" : ""}
{fmt(change)}%
</span>
) : (
<span className="text-xs text-muted-foreground">--</span>
)}
<span className="text-[10px] text-muted-foreground">
{item.fex5 != null ? formatMoney(item.fex5) : "--"}
</span>
</div>
{/* 分割线 */}
<hr className="border-border/40" />
{/* 领涨股 + 强度 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 min-w-0">
<span className="text-[10px] text-muted-foreground shrink-0"></span>
<span className="text-xs font-medium truncate">{item.securityName || "--"}</span>
{stockBoard.label && (
<span
className={`inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold leading-none shrink-0 ${stockBoard.className}`}
>
{stockBoard.label}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{item.f3 != null && (
<span
className={`text-[10px] tabular-nums ${
item.f3 >= 0 ? "text-red-500" : "text-green-500"
}`}
>
{item.f3 >= 0 ? "+" : ""}
{fmt(item.f3)}%
</span>
)}
<span className="text-[10px] text-muted-foreground tabular-nums">
{item.strengthValue ?? "--"}
</span>
</div>
</div>
</CardContent>
</Card>
</Link>
);
}
+5
View File
@@ -0,0 +1,5 @@
// stubgetStockBoard 测试用,仅用于解析 @/lib/api-client 别名(CommonJS
function getApiBaseUrl() {
return "http://localhost:8000";
}
module.exports = { getApiBaseUrl };
+63
View File
@@ -0,0 +1,63 @@
// 最小复现/验证脚本:直接编译 src/lib/stock-api.ts 真实源码,
// 调用 getStockBoard(null),复现「Cannot read properties of null (reading 'startsWith')」。
// 无测试框架,node 直接运行:node tests/get-stock-board-repro.mjs
import fs from "node:fs";
import path from "node:path";
import Module from "node:module";
import ts from "typescript";
const srcPath = path.resolve("src/lib/stock-api.ts");
const source = fs.readFileSync(srcPath, "utf8");
const js = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true },
}).outputText;
const mod = new Module(srcPath);
mod.filename = srcPath;
mod.paths = Module._nodeModulePaths(path.dirname(srcPath));
// 拦截 @/lib/api-client 别名导入(getStockBoard 本身不依赖它)
const origResolve = Module._resolveFilename;
Module._resolveFilename = function (request, ...args) {
if (request === "@/lib/api-client") return path.resolve("tests/_stub-api-client.cjs");
return origResolve.call(this, request, ...args);
};
try {
mod._compile(js, srcPath);
} finally {
Module._resolveFilename = origResolve;
}
const { getStockBoard } = mod.exports;
// ---- 断言 ----
function assertThrows(fn, label) {
try {
fn();
console.log(`${label}: 未抛错`);
process.exitCode = 1;
} catch (e) {
console.log(`${label}: 抛错 -> ${e.message}`);
}
}
function assertNoThrow(fn, label) {
try {
const r = fn();
console.log(`${label}: 未抛错 -> ${JSON.stringify(r)}`);
return r;
} catch (e) {
console.log(`${label}: 抛错 -> ${e.message}`);
process.exitCode = 1;
}
}
// 正常代码
assertNoThrow(() => getStockBoard("600000"), "正常代码 getStockBoard('600000')");
// 崩盘场景:securityCode 为 nullEast Money 题材列表实测存在),修复后应兜底返回主板
assertNoThrow(() => getStockBoard(null), "null securityCode");
assertNoThrow(() => getStockBoard(undefined), "undefined securityCode");
// 空串不崩
assertNoThrow(() => getStockBoard(""), "空串 getStockBoard('')");