mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 13:24:18 +08:00
feat: 行业/概念总览 — WebUI 板块总览双栏目(统计条+热力图/表格+三榜/翻红翻绿+板块弹窗复用)+ /board-mac/overview 聚合端点(多排序归并+15s缓存)
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
# 行业总览 / 概念总览 页面设计(v1 草案)
|
||||
|
||||
> 目标:在 WebUI 左侧导航新增「行业总览」「概念总览」两个栏目,提供全部行业/概念板块的行情总览、
|
||||
> 实用统计与板块异动监控;点击板块复用首页 `BoardDialog` 弹窗(左:分时/日K,右:成分股列表)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状盘点(设计依据,全部为已验证的现成能力)
|
||||
|
||||
### 1.1 后端可复用接口(`src/easy_tdx/web/routers/board_mac.py`,前缀 `/api/v1`)
|
||||
|
||||
| 端点 | 能给什么 | 成本 |
|
||||
|---|---|---|
|
||||
| `GET /board-mac/list?board_type=HY\|HY2\|GN\|FG\|DQ&sort_column=…` | 全量板块列表:`market, code(881xxx/885xxx), name, price, pre_close, sort_value, 领涨股6字段(symbol_*)`。行业(HY)约 86 个、概念(GN)约 270–500 个;每页 150,自动翻页。排序键支持 `SPEED / CHANGE_3D / 5D / 10D / 20D / 60D / YTD` | 低(1–2 页/次) |
|
||||
| `GET /board-mac/members?board_symbol=881001&count=120&sort_type=CHANGE_PCT` | 板块成分股 + 全量报价(自动 80/页翻页),弹窗已在用 | 低 |
|
||||
| `GET /board-mac/summary?board_symbol=…` | `member_count, amount, vol, main_net_amount, main_net_3d/5d, up_count, down_count, members` | 高(每板块一次,概念 300+ 不可全量) |
|
||||
| `GET /board-mac/ranking?board_type=…&sort_by=main_net_amount&top_n=10` | 资金/成交/涨跌幅榜(内部逐板块 summary) | 中,必须限 `top_n` |
|
||||
| `GET /board-mac/change-ranking?board_type=…&days=20` | N 日区间涨幅榜(走板块指数日K) | 低 |
|
||||
| `GET /market/stat` | 全市场上涨/下跌/平盘/涨停/跌停家数(880005/880001/880006) | 低 |
|
||||
| `GET /minute` `/bars`(SH+88xxxx) | 板块指数分时/日K,弹窗已在用;客户端已内置换机容错 | 低 |
|
||||
| `GET /market/session` | 交易时段判定,用于启停自动刷新 | 低 |
|
||||
|
||||
**口径注意(Issue #53)**:`sort_column=CHANGE_PCT` 时 `sort_value` 恒为 0;当日涨跌幅必须由
|
||||
`price / pre_close - 1` 自行计算。其余排序键的 `sort_value` 即该指标值。
|
||||
|
||||
### 1.2 前端可复用资产(`web-ui/src/`)
|
||||
|
||||
| 资产 | 位置 | 复用方式 |
|
||||
|---|---|---|
|
||||
| `BoardDialog.vue` 板块弹窗 | components | **原样复用**:头部 SSE 实时报价+加/移自选;左侧分时(`IntradayChart`)/日K(`StockKline`) tab;右侧成分股涨跌榜(用户所称"下拉框"),点成分股可再叠 `StockDialog` |
|
||||
| `StockDialog.vue` 个股弹窗 | components | 原样复用(成分股点击穿透) |
|
||||
| 自选股体系 | stores + `watchlist.py` | 已支持板块(WatchlistView 板块行开 BoardDialog),板块行可直接加"加自选"星标 |
|
||||
| ECharts 封装 | `echarts-setup.ts` | 已注册 Bar/Heatmap 等;红涨绿跌 `UP_COLOR='#ef4146'` / `DOWN_COLOR='#18a058'` |
|
||||
| API 封装 | `api.ts`(`BASE='/api/v1'` + 统一错误解析) | 新增 2–3 个 fetch 函数 |
|
||||
| 导航/路由 | `App.vue:26-43` + `router.ts:21-40` | 「行情」分组下加两个 RouterLink + 两条路由 |
|
||||
|
||||
**结论:后端仅缺一个"多指标合并"端点,前端为本需求的全部工作量主体。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体设计
|
||||
|
||||
```
|
||||
路由与导航(行情分组)
|
||||
/industries 行业总览 ─┐
|
||||
/concepts 概念总览 ─┴─ 共用 BoardOverviewView.vue,路由 props 区分 board_type
|
||||
行业页额外提供 HY(一级)/HY2(二级) 切换
|
||||
```
|
||||
|
||||
新组件(建议 4 个,均放 `components/`):
|
||||
|
||||
- `BoardStatStrip.vue` — 顶部统计条(板块广度 + 全市场涨跌家数 + 涨跌幅分布直方图)
|
||||
- `BoardTiles.vue` — 热力图模式(CSS Grid 色块,不用 ECharts,500 块内 DOM 足够快)
|
||||
- `BoardRankRail.vue` — 右侧栏:涨幅/跌幅/涨速(异动) 榜 + 翻红翻绿时间线 + 资金榜(懒加载)
|
||||
- `BoardOverviewView.vue` — 主视图:统计条 + [热力图|表格] 切换 + 右栏 + 弹窗编排
|
||||
|
||||
主表格直接内嵌在 View 中(与 DashboardView 同风格),不再抽组件。
|
||||
|
||||
---
|
||||
|
||||
## 3. 功能设计
|
||||
|
||||
### 3.1 页面信息架构(自上而下)
|
||||
|
||||
1. **统计条**:一眼判断今天板块层面强弱
|
||||
2. **工具行**:视图切换 / 排序 / 搜索 / 自动刷新开关
|
||||
3. **主区**:热力图(默认)或 全量表
|
||||
4. **右栏**:榜单 + 异动(热力图模式下承担"看榜"职责,表格模式下可折叠)
|
||||
|
||||
### 3.2 统计条(BoardStatStrip)
|
||||
|
||||
数据源:1 次 `/board-mac/list`(默认排序)+ 1 次 `/market/stat`,全部前端聚合。
|
||||
|
||||
| 指标 | 计算 | 说明 |
|
||||
|---|---|---|
|
||||
| 板块总数 / 上涨 / 下跌 / 平盘 | 按 `change_pct` 正负统计本类型板块 | 如「86 个板块 · 52▲ / 30▼ / 4—」 |
|
||||
| 板块涨幅中位数 | median(change_pct) | 比均值抗极值 |
|
||||
| 全市场涨停/跌停家数 | `/market/stat`(×10 还原) | 判断赚钱效应 |
|
||||
| 涨幅分布直方图 | change_pct 分桶(±1% 一档,截断 ±5%) 小柱图 | ECharts Bar,点击桶可过滤主区(P2) |
|
||||
|
||||
### 3.3 主区一:热力图模式(默认)
|
||||
|
||||
- **布局**:CSS Grid 自适应列(tile 最小宽 ~104px),按当前排序降序排列。
|
||||
- **着色**:红涨绿跌,透明度随 `|change_pct|` 分 5 档增强(复用 UP/DOWN_COLOR)。
|
||||
- **tile 内容**:板块名(超长省略)+ 当日涨跌幅;≥140px 宽度时追加领涨股名+其涨幅。
|
||||
- **交互**:hover 显示 tooltip(代码/价格/涨跌幅/领涨股);**单击打开 BoardDialog**;
|
||||
右键或 tile 上的 ★ 加自选(P2)。
|
||||
- **概念页适配**:500 tile 时顶部加搜索框联动高亮/过滤,tile 缩小至 ~88px。
|
||||
|
||||
> 为什么不用 ECharts heatmap/treemap:treemap 未注册、treemap 按市值定容缺少廉价数据源
|
||||
> (逐板块 summary 不可行),等宽色块已满足"扫一眼谁强谁弱",且交互实现最简单。
|
||||
|
||||
### 3.4 主区二:表格模式(全量、可排序)
|
||||
|
||||
列定义(行业页):
|
||||
|
||||
| 列 | 来源 | 备注 |
|
||||
|---|---|---|
|
||||
| 名称 / 代码 | list | 点击行 → BoardDialog |
|
||||
| 最新价 | list.price | |
|
||||
| **涨跌幅** | price/pre_close | 默认排序列,红绿色阶背景 |
|
||||
| 涨速 | list(sort_column=SPEED).sort_value | 盘中异动核心 |
|
||||
| 3日 / 5日 / 20日 / YTD | list(对应 sort_column).sort_value | 见 3.7 合并策略 |
|
||||
| 轮动标签 | 前端规则(3.6) | 「反弹 / 走强 / 回调 / 补跌」 |
|
||||
| 领涨股(+涨幅) | list.symbol_* | 点领涨股 → StockDialog(stopPropagation) |
|
||||
| 主力净额 | `/board-mac/ranking` 合并(懒加载,见 3.7) | 可空 |
|
||||
| ★ | 自选 | 复用 watchlist API(P2) |
|
||||
|
||||
表格排序纯前端(数据已全量在内存),不再发请求。行业 86 行、概念 ≤500 行,无需虚拟滚动。
|
||||
|
||||
### 3.5 右栏:榜单 + 板块异动(BoardRankRail)
|
||||
|
||||
1. **涨幅榜 Top10 / 跌幅榜 Top10**:前端内存排序,随刷新同步。
|
||||
2. **涨速榜 Top10**("板块异动"主入口):`sort_column=SPEED` 的 `sort_value`,
|
||||
`|speed| ≥ 0.5%/5min` 的条目加闪烁高亮;点击直达 BoardDialog。
|
||||
3. **翻红/翻绿时间线**:前端对相邻两次快照做 diff,`change_pct` 由负转正记「翻红」、
|
||||
正转负记「翻绿」,按时间倒序展示(保留最近 30 条)。这是最低成本的"板块轮动监控"。
|
||||
4. **主力资金榜 Top10**(懒加载 tab):首次展开才调 `/board-mac/ranking?sort_by=main_net_amount&top_n=10`,
|
||||
每 5 分钟刷新一次(接口较贵)。
|
||||
5. **轮动信号卡**(P2):超跌反弹聚集度(当日↑ 且 20日↓ 的板块数)等汇总提示。
|
||||
|
||||
### 3.6 轮动标签规则(表格列 + tile 角标)
|
||||
|
||||
前端基于已合并的多周期涨幅做简单规则标注(阈值可调,初版取 1%):
|
||||
|
||||
| 标签 | 条件 | 含义 |
|
||||
|---|---|---|
|
||||
| 超跌反弹 | 当日 > +1% 且 20日 < -3% | 前期弱势,今日异动 |
|
||||
| 趋势走强 | 当日>0 且 3日>0 且 5日>0 | 多周期共振向上 |
|
||||
| 高位回调 | 当日 < -1% 且 20日 > +5% | 强势板块补跌 |
|
||||
| 趋势走弱 | 当日<0 且 3日<0 且 5日<0 | 多周期共振向下 |
|
||||
|
||||
规则透明、可解释,不引入额外请求。
|
||||
|
||||
### 3.7 数据获取与刷新策略
|
||||
|
||||
**合并请求(关键设计)**:页面一次刷新需要 6 种排序的 list(当日、涨速、3日、5日、20日、YTD)。
|
||||
前端直连将产生 6–12 个 MAC 请求/次。因此新增一个后端聚合端点:
|
||||
|
||||
```
|
||||
GET /api/v1/board-mac/overview?board_type=HY&metrics=speed,3d,5d,20d,ytd
|
||||
→ {
|
||||
board_type: "HY",
|
||||
ts: 1725400000,
|
||||
rows: [{
|
||||
market: 1, code: "881106", name: "种植业",
|
||||
price: 1039.93, pre_close: 1031.20, change_pct: 0.846, // 服务端算好
|
||||
speed: 0.32, chg_3d: 2.1, chg_5d: -0.8, chg_20d: 6.3, chg_ytd: 14.2,
|
||||
leader_code: "600xxx", leader_name: "xxx", leader_change_pct: 10.02
|
||||
}, ...]
|
||||
}
|
||||
```
|
||||
|
||||
- 服务端并发拉取各 sort_column 的 list 后按 code 归并,任一指标缺失置 null(不阻塞整体)。
|
||||
- **服务端缓存 15s**(TTL),多人/多页共享,保护 MAC 服务器。
|
||||
- 前端 30s 轮询(仅交易时段,`/market/session` 判定),手动 ⟳ 随时可用;页面不可见时暂停
|
||||
(`document.visibilitychange`)。
|
||||
- 降级:`/board-mac/overview` 不可用时前端回退为直连 6 次 `/board-mac/list` 并归并(同口径函数复用)。
|
||||
|
||||
**懒加载项**:主力资金榜(首次展开)、板块弹窗内容(点击时,BoardDialog 自理)。
|
||||
|
||||
### 3.8 行业页 vs 概念页差异
|
||||
|
||||
| 维度 | 行业总览 (`HY`) | 概念总览 (`GN`) |
|
||||
|---|---|---|
|
||||
| 板块数量级 | ~86 | ~270–500 |
|
||||
| 二级切换 | 一级(HY) / 二级(HY2) toggle | 无 |
|
||||
| 搜索框 | 有(按名/代码) | **显著位置**,支持拼音/关键词过滤 |
|
||||
| 热力图 tile | 104px | 88px |
|
||||
| 其余(统计条/榜单/异动/弹窗/刷新) | 完全一致 | 完全一致 |
|
||||
|
||||
同一组件路由 props 驱动,后续 `FG`(风格)/`DQ`(地域) 仅需在 App.vue 加入口 + 传 props。
|
||||
|
||||
### 3.9 板块弹窗(点击板块)
|
||||
|
||||
**原样复用 `BoardDialog.vue`**(与首页体验一致):
|
||||
|
||||
- 头部:板块名 + SSE 实时报价 + 加/移自选 + 关闭
|
||||
- 左侧:分时(`/minute`,SH+881xxx/885xxx)/ 日K(`/bars`,MA/MACD/KDJ 等指标前端本地算)
|
||||
- 右侧 330px 成分股涨跌榜:`/board-mac/members`,支持点击成分股叠加 `StockDialog`(五档盘口+K线)
|
||||
|
||||
增量增强(P2,可选):弹窗右侧顶部补一行成分股广度(`up_count/down_count`,来自 summary,单板块成本可接受)。
|
||||
|
||||
---
|
||||
|
||||
## 4. UI 布局设计
|
||||
|
||||
### 4.1 线框
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 行业总览 86个板块 52▲ 30▼ 4— │ 中位 +0.84% │ 涨停23 跌停5 │ ▂▄▆█▆▄▂ 分布图 │
|
||||
├────────────────────────────────────────────────────────────────────────────┤
|
||||
│ [热力图|表格] 排序[涨跌幅▾] [搜索____] HY/HY2 ⟳30s[ON] 上次刷新 14:32:05 │
|
||||
├───────────────────────────────────────────────┬────────────────────────────┤
|
||||
│ │ 涨幅榜 Top10 │
|
||||
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌───────┐ │ 1 种植业 +3.21% │
|
||||
│ │种植业 │ │渔业 │ │煤炭开采│ │... │ │ 2 ... │
|
||||
│ │ +3.21% │ │ +2.88% │ │ +2.10% │ │ │ │ 跌幅榜 Top10 │
|
||||
│ │领涨 xx │ │ │ │ │ │ │ │ 异动(涨速) Top10 ⚡闪烁 │
|
||||
│ └────────┘ └────────┘ └────────┘ └───────┘ │ … │
|
||||
│ ┌────────┐ ┌────────┐ ... │ ────────────────────── │
|
||||
│ │概念名… │ │ │ │ 翻红/翻绿 时间线 │
|
||||
│ └────────┘ └────────┘ │ 14:31 翻红 生物疫苗 │
|
||||
│ │ 14:28 翻绿 房地产开发 │
|
||||
│ (表格模式:3.4 节列定义,同区域替换) │ [资金榜 Top10 ▸懒加载] │
|
||||
├───────────────────────────────────────────────┴────────────────────────────┤
|
||||
│ 单击板块 → BoardDialog 弹窗(左 分时/日K | 右 成分股涨跌榜 → StockDialog) │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 交互与视觉细则
|
||||
|
||||
- **配色**:沿用全局主题变量与 `UP_COLOR/DOWN_COLOR`;色阶透明度 5 档:0–0.5% / 0.5–1 / 1–2 / 2–3 / >3%。
|
||||
- **弹窗层级**:与首页一致(BoardDialog teleport to body,1280px 遮罩弹窗);成分股→StockDialog 叠加。
|
||||
- **空/错误态**:接口失败 → 顶部错误条(api.ts 统一 `{error,detail}` 解析)+ 重试按钮;
|
||||
非交易时段显示"休市中,显示最近收盘数据"徽标。
|
||||
- **响应式**:右栏 ≥1280px 常驻,<1280px 折叠为顶部横向 chips;主区 tile 自动换列。
|
||||
- **localStorage 偏好**:视图模式 / 排序列 / 自动刷新开关(P2)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 改动清单(实施落点)
|
||||
|
||||
### 后端(仅 1 个新端点)
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/easy_tdx/web/routers/board_mac.py` | 新增 `GET /board-mac/overview`:并发聚合多 sort_column 的 `MacClient.get_board_list`,计算 change_pct,按 code 归并;模块级 TTL 缓存(15s) |
|
||||
| `tests/unit/test_board_mac_overview.py` | 单测:归并正确性 / 缺失指标置 null / 缓存命中(mock AsyncMacClient) |
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `web-ui/src/App.vue` | 行情分组 +2 RouterLink(行业总览 `/industries`、概念总览 `/concepts`) |
|
||||
| `web-ui/src/router.ts` | 两条路由 → `BoardOverviewView`,`props: { boardType: 'HY' \| 'GN' }` |
|
||||
| `web-ui/src/types.ts` | `BoardOverviewRow / BoardOverviewResp / BoardRankRow` 类型 |
|
||||
| `web-ui/src/api.ts` | `fetchBoardOverview(boardType, metrics)`;`fetchBoardRanking` 薄封装(复用现有错误处理) |
|
||||
| `web-ui/src/views/BoardOverviewView.vue` | 主视图(统计条编排 / 工具行 / 热力图⇄表格 / 右栏 / 弹窗编排 / 轮询与 diff) |
|
||||
| `web-ui/src/components/BoardStatStrip.vue` | 统计条 + 分布直方图 |
|
||||
| `web-ui/src/components/BoardTiles.vue` | 热力图模式 |
|
||||
| `web-ui/src/components/BoardRankRail.vue` | 榜单 + 翻红翻绿时间线 + 资金懒加载 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 边界与风险
|
||||
|
||||
1. **MAC 服务器可用性**:`get_board_list` 依赖 MAC host(`get_mac_hosts`);客户端已有故障转移,
|
||||
overview 端点失败时前端展示错误条并可回退直连。
|
||||
2. **板块指数 K 线缺数据**:部分服务器不给 88xxxx 日K——仅影响 `change-ranking`(本设计未依赖它做主指标,
|
||||
多周期涨幅全部来自 board list),弹窗日K沿用现有换机容错。
|
||||
3. **概念数量大**:500 个 tile / 行的渲染无压力;`/board-mac/ranking`(逐板块 summary)只允许 top_n 懒加载。
|
||||
4. **盘中口径**:当日涨跌幅一律 `price/pre_close-1`,禁止使用 CHANGE_PCT 排序的 sort_value(恒 0,Issue #53)。
|
||||
5. **刷新风暴**:TTL 缓存 + visibilitychange 暂停 + 30s 间隔,三重保护。
|
||||
|
||||
## 7. 分期计划
|
||||
|
||||
| 期 | 内容 | 预估 |
|
||||
|---|---|---|
|
||||
| **P1(MVP)** | 导航+路由、BoardOverviewView(热力图+表格+搜索)、统计条、右栏涨幅/跌幅/涨速榜、翻红翻绿时间线、30s 自动刷新、BoardDialog 复用、`/board-mac/overview` 端点+缓存+单测 | 1.5–2 天 |
|
||||
| **P2** | 主力资金榜懒加载、轮动标签、tile ★加自选、直方图点击过滤、localStorage 偏好、HY2 切换打磨、休市徽标 | 1 天 |
|
||||
| **P3(远期)** | FG/DQ 入口、板块间对比(多选叠加K线)、"点击板块过滤全市场个股"联动、板块轮动 AI 解读 | 另立项 |
|
||||
|
||||
## 8. 验收清单(P1)
|
||||
|
||||
- [ ] 左侧导航出现两个新栏目,路由/高亮/刷新兜底均正常
|
||||
- [ ] 行业页展示全部 ~86 个行业(HY,且可切 HY2),概念页展示全部概念(≈300+),数量与 `/board-mac/list` 原始返回一致
|
||||
- [ ] 当日涨跌幅与通达信客户端同口径(price/pre_close),排序、搜索、色阶正确
|
||||
- [ ] 单击板块 tile/行 → BoardDialog:分时、日K、成分股榜、成分股→StockDialog 全链路可用
|
||||
- [ ] 交易时段 30s 自动刷新且网络面板只有 1 个 overview 请求;休市自动停止
|
||||
- [ ] 涨速榜闪烁、翻红/翻绿时间线在盘中可见事件产生
|
||||
- [ ] 断开 MAC 服务器时出现错误条 + 重试,页面不白屏
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
@@ -18,6 +20,24 @@ from easy_tdx.web.schemas import DataFrameResponse, DictResponse
|
||||
|
||||
router = APIRouter(tags=["board-mac"])
|
||||
|
||||
# overview 端点:metrics 参数名 → 返回行字段名(值来自对应排序键的 sort_value)
|
||||
_OVERVIEW_METRIC_FIELDS: dict[str, str] = {
|
||||
"SPEED": "speed",
|
||||
"CHANGE_3D": "chg_3d",
|
||||
"CHANGE_5D": "chg_5d",
|
||||
"CHANGE_10D": "chg_10d",
|
||||
"CHANGE_20D": "chg_20d",
|
||||
"CHANGE_60D": "chg_60d",
|
||||
"YTD": "chg_ytd",
|
||||
}
|
||||
_OVERVIEW_TTL = 15.0
|
||||
# (board_type, metrics) -> (monotonic 截止时间, payload)。无锁:并发重复拉取
|
||||
# 无害(AsyncMacClient 连接内本就串行),省去跨事件循环的锁生命周期问题。
|
||||
_overview_cache: dict[tuple[str, tuple[str, ...]], tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
# 可在单测中 monkeypatch 以控制 TTL 判定
|
||||
_now = time.monotonic
|
||||
|
||||
|
||||
def _df_resp(df: Any) -> DataFrameResponse:
|
||||
return DataFrameResponse.from_dataframe(df)
|
||||
@@ -127,3 +147,84 @@ async def board_change_ranking(
|
||||
ascending=ascending,
|
||||
)
|
||||
return _df_resp(df)
|
||||
|
||||
|
||||
@router.get("/board-mac/overview", response_model=DictResponse)
|
||||
async def board_overview(
|
||||
board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"),
|
||||
metrics: str = Query(
|
||||
"SPEED,CHANGE_3D,CHANGE_5D,CHANGE_20D,YTD",
|
||||
description=(
|
||||
"附加指标(逗号分隔,取自各排序键的 sort_value): "
|
||||
"SPEED/CHANGE_3D/CHANGE_5D/CHANGE_10D/CHANGE_20D/CHANGE_60D/YTD"
|
||||
),
|
||||
),
|
||||
count: int = Query(2000, ge=1, le=20000),
|
||||
client: Any = Depends(get_mac_client),
|
||||
) -> DictResponse:
|
||||
"""板块总览:一次返回全部板块的当日涨跌幅 + 领涨股 + 多周期指标。
|
||||
|
||||
以默认(涨跌幅)排序的板块列表为基表归并各 metrics 排序列的 sort_value,
|
||||
避免前端直连 N 次 ``/board-mac/list``。结果服务端缓存 15s。
|
||||
当日涨跌幅按 price/pre_close-1 计算(CHANGE_PCT 的 sort_value 恒 0)。
|
||||
"""
|
||||
bt = board_type_from_str(board_type)
|
||||
sort_names = [m.strip().upper() for m in metrics.split(",") if m.strip()]
|
||||
invalid = [m for m in sort_names if m not in _OVERVIEW_METRIC_FIELDS]
|
||||
if invalid:
|
||||
valid = ", ".join(_OVERVIEW_METRIC_FIELDS)
|
||||
raise ValueError(f"无效指标 '{','.join(invalid)}',可选值: {valid}")
|
||||
|
||||
cache_key = (bt.name, tuple(sort_names))
|
||||
cached = _overview_cache.get(cache_key)
|
||||
if cached is not None and _now() < cached[0]:
|
||||
return DictResponse.from_dict(cached[1])
|
||||
|
||||
results = await asyncio.gather(
|
||||
client.get_board_list(board_type=bt, count=count),
|
||||
*(
|
||||
client.get_board_list(board_type=bt, count=count, sort_column=board_sort_from_str(name))
|
||||
for name in sort_names
|
||||
),
|
||||
)
|
||||
base_df, metric_dfs = results[0], list(results[1:])
|
||||
|
||||
metric_values: dict[str, dict[str, float]] = {}
|
||||
for name, df in zip(sort_names, metric_dfs):
|
||||
field = _OVERVIEW_METRIC_FIELDS[name]
|
||||
col: dict[str, float] = {}
|
||||
if df is not None and not df.empty:
|
||||
for code, value in zip(df["code"], df["sort_value"]):
|
||||
col[str(code)] = float(value)
|
||||
metric_values[field] = col
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
if base_df is not None and not base_df.empty:
|
||||
for record in base_df.to_dict(orient="records"):
|
||||
price = float(record["price"])
|
||||
pre_close = float(record["pre_close"])
|
||||
sym_price = float(record.get("symbol_price") or 0.0)
|
||||
sym_pre_close = float(record.get("symbol_pre_close") or 0.0)
|
||||
row: dict[str, Any] = {
|
||||
"market": int(record["market"]),
|
||||
"code": str(record["code"]),
|
||||
"name": str(record["name"]),
|
||||
"price": price,
|
||||
"pre_close": pre_close,
|
||||
"change_pct": round((price / pre_close - 1) * 100, 3) if pre_close else None,
|
||||
"leader_code": str(record.get("symbol_code") or ""),
|
||||
"leader_name": str(record.get("symbol_name") or ""),
|
||||
"leader_change_pct": (
|
||||
round((sym_price / sym_pre_close - 1) * 100, 3) if sym_pre_close else None
|
||||
),
|
||||
}
|
||||
for field, values in metric_values.items():
|
||||
row[field] = values.get(str(record["code"]))
|
||||
# 未请求的指标字段补 null,保证行结构稳定(前端类型固定)
|
||||
for field in _OVERVIEW_METRIC_FIELDS.values():
|
||||
row.setdefault(field, None)
|
||||
rows.append(row)
|
||||
|
||||
payload = {"board_type": bt.name, "ts": int(time.time()), "count": len(rows), "rows": rows}
|
||||
_overview_cache[cache_key] = (_now() + _OVERVIEW_TTL, payload)
|
||||
return DictResponse.from_dict(payload)
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""/board-mac/overview 聚合端点单测(离线,mock MAC 客户端)。
|
||||
|
||||
覆盖:多排序键归并、当日涨跌幅口径(price/pre_close-1)、缺失指标置 null、
|
||||
TTL 缓存命中、无效指标 400、空列表。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _board_df(rows: list[dict]) -> object:
|
||||
import pandas as pd
|
||||
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def _board_row(
|
||||
code: str,
|
||||
name: str,
|
||||
price: float,
|
||||
pre_close: float,
|
||||
sort_value: float = 0.0,
|
||||
leader: tuple[str, str, float, float] | None = None,
|
||||
) -> dict:
|
||||
leader = leader or ("600000", "领涨股", price * 1.05, price)
|
||||
return {
|
||||
"market": 1,
|
||||
"code": code,
|
||||
"name": name,
|
||||
"price": price,
|
||||
"sort_value": sort_value,
|
||||
"pre_close": pre_close,
|
||||
"symbol_market": 1,
|
||||
"symbol_code": leader[0],
|
||||
"symbol_name": leader[1],
|
||||
"symbol_price": leader[2],
|
||||
"symbol_pre_close": leader[3],
|
||||
}
|
||||
|
||||
|
||||
class _FakeOverviewMacClient:
|
||||
"""按 BoardSortColumn 名称返回预置 DataFrame 的替身客户端。"""
|
||||
|
||||
def __init__(self, frames: dict[str, object]):
|
||||
import pandas as pd
|
||||
|
||||
self._frames = frames
|
||||
self._empty = pd.DataFrame()
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def get_board_list(self, board_type=None, count=10000, sort_column=None):
|
||||
name = getattr(sort_column, "name", None) or "CHANGE_PCT" # 与真客户端默认一致
|
||||
self.calls.append(name)
|
||||
return self._frames.get(name, self._empty)
|
||||
|
||||
|
||||
def _overview_app(mac_client):
|
||||
from fastapi import FastAPI
|
||||
|
||||
from easy_tdx.web.errors import register_exception_handlers
|
||||
from easy_tdx.web.routers import board_mac
|
||||
|
||||
app = FastAPI()
|
||||
register_exception_handlers(app)
|
||||
app.include_router(board_mac.router, prefix="/api/v1")
|
||||
app.state.tdx_client = object()
|
||||
app.state.mac_client = mac_client
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_cache():
|
||||
from easy_tdx.web.routers import board_mac
|
||||
|
||||
board_mac._overview_cache.clear()
|
||||
yield
|
||||
board_mac._overview_cache.clear()
|
||||
|
||||
|
||||
def _get_overview(client, board_type="HY", metrics="SPEED,CHANGE_20D"):
|
||||
return client.get(
|
||||
"/api/v1/board-mac/overview",
|
||||
params={"board_type": board_type, "metrics": metrics},
|
||||
)
|
||||
|
||||
|
||||
def test_overview_merge_and_change_pct():
|
||||
"""基表 + 各排序键归并;涨跌幅按 price/pre_close-1 计算。"""
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
frames = {
|
||||
"CHANGE_PCT": _board_df(
|
||||
[
|
||||
_board_row(
|
||||
"881106", "种植业", 1039.93, 1031.20, leader=("600100", "A股票", 11.0, 10.0)
|
||||
),
|
||||
_board_row(
|
||||
"881101", "煤炭开采", 2200.0, 2244.0, leader=("600200", "B股票", 9.5, 10.0)
|
||||
),
|
||||
]
|
||||
),
|
||||
"SPEED": _board_df(
|
||||
[
|
||||
_board_row("881106", "种植业", 1039.93, 1031.20, sort_value=0.52),
|
||||
_board_row("881101", "煤炭开采", 2200.0, 2244.0, sort_value=-0.11),
|
||||
]
|
||||
),
|
||||
"CHANGE_20D": _board_df(
|
||||
[
|
||||
_board_row("881106", "种植业", 1039.93, 1031.20, sort_value=6.3),
|
||||
_board_row("881101", "煤炭开采", 2200.0, 2244.0, sort_value=-2.4),
|
||||
]
|
||||
),
|
||||
}
|
||||
fake = _FakeOverviewMacClient(frames)
|
||||
with TestClient(_overview_app(fake)) as client:
|
||||
resp = _get_overview(client)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["board_type"] == "HY"
|
||||
assert data["count"] == 2
|
||||
|
||||
rows = {r["code"]: r for r in data["rows"]}
|
||||
hy = rows["881106"]
|
||||
# 1039.93/1031.20-1 = +0.8465%
|
||||
assert hy["change_pct"] == pytest.approx(0.846, abs=0.01)
|
||||
assert hy["speed"] == 0.52
|
||||
assert hy["chg_20d"] == 6.3
|
||||
assert hy["chg_5d"] is None # 未请求的指标置 null
|
||||
assert hy["leader_name"] == "A股票"
|
||||
assert hy["leader_change_pct"] == pytest.approx(10.0, abs=0.01)
|
||||
|
||||
mt = rows["881101"]
|
||||
assert mt["change_pct"] == pytest.approx(-1.961, abs=0.01)
|
||||
assert mt["leader_change_pct"] == pytest.approx(-5.0, abs=0.01)
|
||||
|
||||
# 基表(涨跌幅排序) + SPEED + CHANGE_20D 共 3 次调用
|
||||
assert sorted(fake.calls) == ["CHANGE_20D", "CHANGE_PCT", "SPEED"]
|
||||
|
||||
|
||||
def test_overview_cache_hit_within_ttl():
|
||||
"""TTL 内命中缓存,不再触发 MAC 调用;时间推进后重新拉取。"""
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from easy_tdx.web.routers import board_mac
|
||||
|
||||
fake = _FakeOverviewMacClient(
|
||||
{"CHANGE_PCT": _board_df([_board_row("881001", "软件服务", 5000.0, 4900.0)])}
|
||||
)
|
||||
clock = {"t": 100.0}
|
||||
board_mac._now = lambda: clock["t"] # type: ignore[assignment]
|
||||
try:
|
||||
with TestClient(_overview_app(fake)) as client:
|
||||
_get_overview(client)
|
||||
_get_overview(client)
|
||||
assert fake.calls.count("CHANGE_PCT") == 1
|
||||
|
||||
clock["t"] += board_mac._OVERVIEW_TTL + 1
|
||||
with TestClient(_overview_app(fake)) as client:
|
||||
_get_overview(client)
|
||||
assert fake.calls.count("CHANGE_PCT") == 2
|
||||
finally:
|
||||
board_mac._now = board_mac.time.monotonic # type: ignore[assignment]
|
||||
|
||||
|
||||
def test_overview_cache_key_separates_board_type():
|
||||
"""不同 board_type 的缓存相互独立。"""
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
frames = {
|
||||
"CHANGE_PCT": _board_df([_board_row("881001", "软件服务", 5000.0, 4900.0)]),
|
||||
"GN": None,
|
||||
}
|
||||
fake = _FakeOverviewMacClient(frames)
|
||||
with TestClient(_overview_app(fake)) as client:
|
||||
_get_overview(client, board_type="HY")
|
||||
_get_overview(client, board_type="GN")
|
||||
assert fake.calls.count("CHANGE_PCT") == 2
|
||||
|
||||
|
||||
def test_overview_invalid_metric_returns_400():
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
fake = _FakeOverviewMacClient({})
|
||||
with TestClient(_overview_app(fake)) as client:
|
||||
resp = _get_overview(client, metrics="SPEED,NOT_A_METRIC")
|
||||
assert resp.status_code == 400
|
||||
assert "NOT_A_METRIC" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_overview_empty_base_list():
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
fake = _FakeOverviewMacClient({})
|
||||
with TestClient(_overview_app(fake)) as client:
|
||||
resp = _get_overview(client)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()["data"]
|
||||
assert data["count"] == 0
|
||||
assert data["rows"] == []
|
||||
|
||||
|
||||
def test_overview_zero_pre_close_change_pct_null():
|
||||
"""pre_close 为 0(无行情)时涨跌幅为 null 而非异常/除零。"""
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
frames = {"CHANGE_PCT": _board_df([_board_row("881999", "空数据板块", 0.0, 0.0)])}
|
||||
fake = _FakeOverviewMacClient(frames)
|
||||
with TestClient(_overview_app(fake)) as client:
|
||||
resp = _get_overview(client)
|
||||
assert resp.status_code == 200
|
||||
row = resp.json()["data"]["rows"][0]
|
||||
assert row["change_pct"] is None
|
||||
assert row["leader_change_pct"] is None
|
||||
@@ -26,6 +26,8 @@ const sseLabel: Record<string, string> = {
|
||||
<nav class="side-nav">
|
||||
<div class="nav-group">行情</div>
|
||||
<RouterLink to="/" exact-active-class="active">市场看板</RouterLink>
|
||||
<RouterLink to="/industries" active-class="active">行业总览</RouterLink>
|
||||
<RouterLink to="/concepts" active-class="active">概念总览</RouterLink>
|
||||
<RouterLink to="/watchlist" active-class="active">自选行情</RouterLink>
|
||||
<RouterLink to="/leaders" active-class="active">龙头池</RouterLink>
|
||||
<RouterLink to="/ccpm" active-class="active">期货持仓排名</RouterLink>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BacktestRequest,
|
||||
BacktestResult,
|
||||
Bar,
|
||||
BoardOverviewResp,
|
||||
BoardRow,
|
||||
Category,
|
||||
CoreLeaderRow,
|
||||
@@ -567,6 +568,20 @@ export async function fetchBoards(boardType = 'HY', count = 60): Promise<BoardRo
|
||||
})
|
||||
}
|
||||
|
||||
/** 板块总览(服务端一次归并当日涨跌幅 + 涨速 + 多周期涨幅,15s 缓存)。
|
||||
* 当日涨跌幅后端已按 price/pre_close-1 计算;未请求/缺失的周期指标为 null。 */
|
||||
export async function fetchBoardOverview(
|
||||
boardType: string,
|
||||
metrics = 'SPEED,CHANGE_3D,CHANGE_5D,CHANGE_20D,YTD',
|
||||
): Promise<BoardOverviewResp> {
|
||||
const params = new URLSearchParams({ board_type: boardType, metrics })
|
||||
const resp = await fetch(`${BASE}/board-mac/overview?${params}`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
// 后端 DictResponse 包装为 {data: {...}},这里解包
|
||||
const body = (await resp.json()) as { data: BoardOverviewResp }
|
||||
return body.data
|
||||
}
|
||||
|
||||
/** 市场异动流(火箭发射/大笔买入/封涨停板/打开跌停板/快速反弹等)。 */
|
||||
export async function fetchUnusual(market: 'SH' | 'SZ', count = 50): Promise<Record<string, unknown>[]> {
|
||||
const params = new URLSearchParams({ market, count: String(count) })
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
// 板块总览右栏:涨幅/跌幅/涨速异动 Top10 + 翻红翻绿时间线。
|
||||
// 全部由父组件传入的全量 rows 在内存内计算,不额外发请求。
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { fmtPctSigned } from '../format'
|
||||
import type { BoardFlipEvent, BoardOverviewRow } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: BoardOverviewRow[]
|
||||
flips: BoardFlipEvent[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [row: BoardOverviewRow] }>()
|
||||
|
||||
const TOP_N = 10
|
||||
|
||||
function topBy(
|
||||
rows: BoardOverviewRow[],
|
||||
key: 'change_pct' | 'speed',
|
||||
order: 'desc' | 'asc' = 'desc',
|
||||
n = TOP_N,
|
||||
): BoardOverviewRow[] {
|
||||
const sorted = rows
|
||||
.filter((r) => r[key] !== null && r[key] !== undefined)
|
||||
.sort((a, b) => (b[key] as number) - (a[key] as number))
|
||||
return (order === 'desc' ? sorted : sorted.slice().reverse()).slice(0, n)
|
||||
}
|
||||
|
||||
const topGainers = computed(() => topBy(props.rows, 'change_pct'))
|
||||
const topLosers = computed(() => topBy(props.rows, 'change_pct', 'asc'))
|
||||
/** 涨速异动:按 |speed| 降序,急拉急杀都算;|speed| ≥ 0.5% 标闪烁。 */
|
||||
const rowsByAbsSpeed = computed(() =>
|
||||
props.rows
|
||||
.filter((r) => r.speed !== null && r.speed !== undefined)
|
||||
.sort((a, b) => Math.abs(b.speed as number) - Math.abs(a.speed as number)),
|
||||
)
|
||||
const speedMovers = computed(() => rowsByAbsSpeed.value.slice(0, TOP_N))
|
||||
|
||||
const recentFlips = computed(() => props.flips.slice(0, 12))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rail">
|
||||
<div class="card rank-card">
|
||||
<h3>涨幅榜 <span class="dim">Top{{ TOP_N }}</span></h3>
|
||||
<div v-for="(r, i) in topGainers" :key="r.code" class="rank-row clickable" @click="emit('select', r)">
|
||||
<span class="r-idx dim">{{ i + 1 }}</span>
|
||||
<span class="r-name">{{ r.name }}</span>
|
||||
<span class="r-pct mono up">{{ fmtPctSigned(r.change_pct) }}</span>
|
||||
</div>
|
||||
<div v-if="topGainers.length === 0" class="rank-empty dim">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<div class="card rank-card">
|
||||
<h3>跌幅榜 <span class="dim">Top{{ TOP_N }}</span></h3>
|
||||
<div v-for="(r, i) in topLosers" :key="r.code" class="rank-row clickable" @click="emit('select', r)">
|
||||
<span class="r-idx dim">{{ i + 1 }}</span>
|
||||
<span class="r-name">{{ r.name }}</span>
|
||||
<span class="r-pct mono down">{{ fmtPctSigned(r.change_pct) }}</span>
|
||||
</div>
|
||||
<div v-if="topLosers.length === 0" class="rank-empty dim">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<div class="card rank-card">
|
||||
<h3>异动 · 涨速 <span class="dim">|涨速| Top{{ TOP_N }}</span></h3>
|
||||
<div
|
||||
v-for="r in speedMovers"
|
||||
:key="r.code"
|
||||
class="rank-row clickable"
|
||||
:class="{ flashing: r.speed !== null && Math.abs(r.speed) >= 0.5 }"
|
||||
@click="emit('select', r)"
|
||||
>
|
||||
<span class="r-name">{{ r.name }}</span>
|
||||
<span class="r-pct mono" :class="(r.speed ?? 0) >= 0 ? 'up' : 'down'">{{ fmtPctSigned(r.speed) }}</span>
|
||||
</div>
|
||||
<div v-if="speedMovers.length === 0" class="rank-empty dim">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<div class="card rank-card">
|
||||
<h3>板块轮动 <span class="dim">翻红 / 翻绿</span></h3>
|
||||
<div v-if="recentFlips.length === 0" class="rank-empty dim">刷新期间暂无翻红/翻绿</div>
|
||||
<div v-for="(f, i) in recentFlips" :key="`${f.code}-${f.time}-${i}`" class="flip-row">
|
||||
<span class="f-time mono dim">{{ f.time }}</span>
|
||||
<span class="f-type" :class="f.type === 'up' ? 'up' : 'down'">{{ f.type === 'up' ? '翻红' : '翻绿' }}</span>
|
||||
<span class="f-name">{{ f.name }}</span>
|
||||
<span class="r-pct mono" :class="f.type === 'up' ? 'up' : 'down'">{{ fmtPctSigned(f.change_pct) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.rank-card {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.rank-card h3 {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.rank-card h3 .dim {
|
||||
font-size: 10.5px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.rank-row,
|
||||
.flip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2.5px 4px;
|
||||
font-size: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.rank-row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.rank-row.clickable:hover {
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
.r-idx {
|
||||
width: 16px;
|
||||
font-size: 10.5px;
|
||||
text-align: right;
|
||||
}
|
||||
.r-name,
|
||||
.f-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.r-pct {
|
||||
font-weight: 600;
|
||||
}
|
||||
.flashing {
|
||||
animation: flash 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes flash {
|
||||
0%,
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
50% {
|
||||
background: rgba(240, 160, 32, 0.18);
|
||||
}
|
||||
}
|
||||
.flip-row {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.f-time {
|
||||
font-size: 10.5px;
|
||||
}
|
||||
.f-type {
|
||||
width: 30px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rank-empty {
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
// 板块总览统计条:板块广度(上涨/下跌/平盘)+ 涨幅中位数 + 全市场涨停/跌停
|
||||
// + 涨跌幅分布直方图(纯 CSS 柱,±1% 一档,截断 ±3% 外两档)。
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { fmtPctSigned } from '../format'
|
||||
import type { BoardOverviewRow, MarketStat } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: BoardOverviewRow[]
|
||||
stat: MarketStat | null
|
||||
}>()
|
||||
|
||||
const breadth = computed(() => {
|
||||
let up = 0
|
||||
let down = 0
|
||||
let flat = 0
|
||||
for (const r of props.rows) {
|
||||
const p = r.change_pct
|
||||
if (p === null || p === undefined) continue
|
||||
if (p > 0) up++
|
||||
else if (p < 0) down++
|
||||
else flat++
|
||||
}
|
||||
return { up, down, flat }
|
||||
})
|
||||
|
||||
const medianPct = computed<number | null>(() => {
|
||||
const vals = props.rows
|
||||
.map((r) => r.change_pct)
|
||||
.filter((v): v is number => v !== null && v !== undefined)
|
||||
.sort((a, b) => a - b)
|
||||
if (vals.length === 0) return null
|
||||
const mid = Math.floor(vals.length / 2)
|
||||
return vals.length % 2 ? vals[mid] : (vals[mid - 1] + vals[mid]) / 2
|
||||
})
|
||||
|
||||
// 直方图:[-Inf,-3) [-3,-2) [-2,-1) [-1,0) [0,1) [1,2) [2,3) [3,+Inf)
|
||||
const BUCKETS = [
|
||||
{ label: '≤-3', dir: 'down' as const },
|
||||
{ label: '-3~-2', dir: 'down' as const },
|
||||
{ label: '-2~-1', dir: 'down' as const },
|
||||
{ label: '-1~0', dir: 'down' as const },
|
||||
{ label: '0~1', dir: 'up' as const },
|
||||
{ label: '1~2', dir: 'up' as const },
|
||||
{ label: '2~3', dir: 'up' as const },
|
||||
{ label: '≥3', dir: 'up' as const },
|
||||
]
|
||||
|
||||
const hist = computed(() => {
|
||||
const counts = new Array(BUCKETS.length).fill(0) as number[]
|
||||
for (const r of props.rows) {
|
||||
const p = r.change_pct
|
||||
if (p === null || p === undefined) continue
|
||||
const idx = p < -3 ? 0 : p < -2 ? 1 : p < -1 ? 2 : p < 0 ? 3 : p < 1 ? 4 : p < 2 ? 5 : p < 3 ? 6 : 7
|
||||
counts[idx]++
|
||||
}
|
||||
const max = Math.max(...counts, 1)
|
||||
return BUCKETS.map((b, i) => ({
|
||||
...b,
|
||||
count: counts[i],
|
||||
// 68% 上限给顶部数字留空间
|
||||
height: counts[i] > 0 ? `${Math.max((counts[i] / max) * 68, 8)}%` : '0',
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stat-strip card">
|
||||
<div class="stat-cell">
|
||||
<span class="stat-label">板块广度</span>
|
||||
<span class="stat-value">
|
||||
<span class="mono">{{ rows.length }}</span> 个
|
||||
<span class="mono up"> {{ breadth.up }}▲</span>
|
||||
<span class="mono down"> {{ breadth.down }}▼</span>
|
||||
<span v-if="breadth.flat" class="mono flat"> {{ breadth.flat }}—</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-label">涨幅中位数</span>
|
||||
<span class="stat-value mono" :class="medianPct === null ? 'flat' : medianPct > 0 ? 'up' : medianPct < 0 ? 'down' : 'flat'">
|
||||
{{ medianPct === null ? '-' : fmtPctSigned(medianPct) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-cell">
|
||||
<span class="stat-label">全市场涨停 / 跌停</span>
|
||||
<span class="stat-value mono">
|
||||
<span class="up">{{ stat?.limit_up_count ?? '-' }}</span>
|
||||
<span class="dim"> / </span>
|
||||
<span class="down">{{ stat?.limit_down_count ?? '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-cell hist-cell">
|
||||
<span class="stat-label">板块涨跌幅分布</span>
|
||||
<div class="hist">
|
||||
<div v-for="b in hist" :key="b.label" class="hist-col">
|
||||
<div class="hist-bar" :class="b.dir" :style="{ height: b.height }"></div>
|
||||
<span v-if="b.count" class="hist-n mono">{{ b.count }}</span>
|
||||
<div class="hist-label">{{ b.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-strip {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 20px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.stat-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hist-cell {
|
||||
flex: 1;
|
||||
min-width: 260px;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hist {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
height: 44px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.hist-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.hist-bar {
|
||||
width: 100%;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.hist-bar.up {
|
||||
background: var(--up);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.hist-bar.down {
|
||||
background: var(--down);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.hist-n {
|
||||
font-size: 9.5px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.hist-label {
|
||||
font-size: 9.5px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
// 板块热力图:等宽色块网格(纯 CSS Grid,红涨绿跌、透明度随涨跌幅增强)。
|
||||
// 数据已由父组件排序过滤;单击色块打开板块弹窗。500 块内 DOM 足够流畅。
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { fmt2, fmtPctSigned } from '../format'
|
||||
import type { BoardOverviewRow } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: BoardOverviewRow[]
|
||||
/** tile 最小宽度(概念页可传小值) */
|
||||
tileMinWidth?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [row: BoardOverviewRow] }>()
|
||||
|
||||
/** 涨跌幅 → 背景色(A股红涨绿跌,|pct| 分 5 档增强)。 */
|
||||
function tileStyle(r: BoardOverviewRow): Record<string, string> {
|
||||
const p = r.change_pct
|
||||
if (p === null || p === undefined || p === 0) {
|
||||
return { background: 'var(--bg-elevated)', color: 'var(--text-muted)' }
|
||||
}
|
||||
const mag = Math.abs(p)
|
||||
const tier = mag > 3 ? 0.82 : mag > 2 ? 0.62 : mag > 1 ? 0.42 : mag > 0.5 ? 0.26 : 0.14
|
||||
const base = p > 0 ? '239, 65, 70' : '24, 160, 88' // var(--up) / var(--down) 的 rgb
|
||||
return { background: `rgba(${base}, ${tier})`, color: '#fff' }
|
||||
}
|
||||
|
||||
function tileTitle(r: BoardOverviewRow): string {
|
||||
const parts = [`${r.name} (${r.code})`]
|
||||
if (r.price) parts.push(`最新 ${fmt2(r.price)}`)
|
||||
parts.push(`涨跌幅 ${fmtPctSigned(r.change_pct)}`)
|
||||
if (r.speed !== null && r.speed !== undefined) parts.push(`涨速 ${fmtPctSigned(r.speed)}`)
|
||||
if (r.leader_name) parts.push(`领涨 ${r.leader_name} ${fmtPctSigned(r.leader_change_pct)}`)
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${props.tileMinWidth ?? 104}px, 1fr))`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tiles" :style="gridStyle">
|
||||
<div
|
||||
v-for="r in rows"
|
||||
:key="r.code"
|
||||
class="tile"
|
||||
:style="tileStyle(r)"
|
||||
:title="tileTitle(r)"
|
||||
@click="emit('select', r)"
|
||||
>
|
||||
<span class="t-name">{{ r.name }}</span>
|
||||
<span class="t-pct mono">{{ fmtPctSigned(r.change_pct) }}</span>
|
||||
<span v-if="r.leader_name" class="t-leader">
|
||||
{{ r.leader_name }} <span class="mono">{{ fmtPctSigned(r.leader_change_pct) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="rows.length === 0" class="tiles-empty">没有匹配的板块</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tiles {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
min-height: 58px;
|
||||
overflow: hidden;
|
||||
transition: filter 0.1s;
|
||||
}
|
||||
.tile:hover {
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
.t-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.t-pct {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.t-leader {
|
||||
font-size: 10px;
|
||||
opacity: 0.85;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tiles-empty {
|
||||
grid-column: 1 / -1;
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import BacktestView from './views/BacktestView.vue'
|
||||
import BoardOverviewView from './views/BoardOverviewView.vue'
|
||||
import CcpmView from './views/CcpmView.vue'
|
||||
import CompareView from './views/CompareView.vue'
|
||||
import CoreLeadersView from './views/CoreLeadersView.vue'
|
||||
@@ -20,6 +21,9 @@ import WatchlistView from './views/WatchlistView.vue'
|
||||
// + 设置(/settings 服务器 / /llm AI 模型)。
|
||||
const routes = [
|
||||
{ path: '/', name: 'dashboard', component: DashboardView },
|
||||
// 行业/概念总览(同一视图组件,路由 props 区分板块类型)
|
||||
{ path: '/industries', name: 'industries', component: BoardOverviewView, props: { boardType: 'HY' } },
|
||||
{ path: '/concepts', name: 'concepts', component: BoardOverviewView, props: { boardType: 'GN' } },
|
||||
{ path: '/watchlist', name: 'watchlist', component: WatchlistView },
|
||||
{ path: '/backtest', name: 'backtest', component: BacktestView },
|
||||
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
|
||||
|
||||
@@ -558,6 +558,46 @@ export interface RankRow {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ── 行业/概念总览(GET /api/v1/board-mac/overview,服务端归并多排序键) ──────
|
||||
|
||||
/** 板块总览行。多周期指标未请求/缺失时为 null;当日涨跌幅由后端按 price/pre_close 计算。 */
|
||||
export interface BoardOverviewRow {
|
||||
market: number
|
||||
code: string
|
||||
name: string
|
||||
price: number
|
||||
pre_close: number
|
||||
change_pct: number | null
|
||||
speed: number | null
|
||||
chg_3d: number | null
|
||||
chg_5d: number | null
|
||||
chg_10d: number | null
|
||||
chg_20d: number | null
|
||||
chg_60d: number | null
|
||||
chg_ytd: number | null
|
||||
leader_code: string
|
||||
leader_name: string
|
||||
leader_change_pct: number | null
|
||||
}
|
||||
|
||||
export interface BoardOverviewResp {
|
||||
board_type: string
|
||||
/** 数据抓取时刻(epoch 秒) */
|
||||
ts: number
|
||||
count: number
|
||||
rows: BoardOverviewRow[]
|
||||
}
|
||||
|
||||
/** 板块翻红/翻绿事件(前端对相邻两次快照 diff 产生)。 */
|
||||
export interface BoardFlipEvent {
|
||||
code: string
|
||||
name: string
|
||||
/** up = 翻红(负转正),down = 翻绿(正转负) */
|
||||
type: 'up' | 'down'
|
||||
time: string
|
||||
change_pct: number
|
||||
}
|
||||
|
||||
// ── Walk-Forward 样本外验证(v1.27 POST /backtest/wf/run/async)──────────────
|
||||
|
||||
export interface WalkForwardWindow {
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
<script setup lang="ts">
|
||||
// 行业/概念总览(/industries、/concepts 共用,路由 props 区分 board_type)。
|
||||
// 顶部统计条 + [热力图|表格] 主区 + 右栏榜单异动;单击板块复用 BoardDialog。
|
||||
// 数据走 /board-mac/overview(服务端归并多排序键,15s 缓存),30s 轮询、休市暂停。
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
fetchBoardOverview,
|
||||
fetchMarketStat,
|
||||
formatError,
|
||||
} from '../api'
|
||||
import { dirClass, fmt2, fmtPctSigned } from '../format'
|
||||
import BoardDialog from '../components/BoardDialog.vue'
|
||||
import BoardRankRail from '../components/BoardRankRail.vue'
|
||||
import BoardStatStrip from '../components/BoardStatStrip.vue'
|
||||
import BoardTiles from '../components/BoardTiles.vue'
|
||||
import type { BoardFlipEvent, BoardOverviewRow, MarketStat } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
boardType: 'HY' | 'GN'
|
||||
}>()
|
||||
|
||||
// 行业页可切一级(HY)/二级(HY2)分类;概念页固定 GN
|
||||
const activeType = ref<'HY' | 'HY2' | 'GN'>(props.boardType)
|
||||
const isIndustry = computed(() => props.boardType !== 'GN')
|
||||
|
||||
const title = computed(() => (props.boardType === 'GN' ? '概念总览' : '行业总览'))
|
||||
const titleSub = computed(() =>
|
||||
props.boardType !== 'GN' && activeType.value === 'HY2' ? '二级' : '',
|
||||
)
|
||||
|
||||
// ── 数据状态 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const rows = ref<BoardOverviewRow[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const lastRefresh = ref('')
|
||||
const stat = ref<MarketStat | null>(null)
|
||||
|
||||
async function loadOverview() {
|
||||
loading.value = rows.value.length === 0
|
||||
error.value = ''
|
||||
try {
|
||||
const resp = await fetchBoardOverview(activeType.value)
|
||||
rows.value = resp.rows
|
||||
lastRefresh.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
diffFlips(resp.rows)
|
||||
} catch (e) {
|
||||
error.value = formatError(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStat() {
|
||||
try {
|
||||
stat.value = await fetchMarketStat()
|
||||
} catch {
|
||||
stat.value = null // 统计条独立降级,不打扰主数据
|
||||
}
|
||||
}
|
||||
|
||||
// ── 翻红/翻绿:对相邻两次快照按涨跌幅符号 diff ────────────────────────────────
|
||||
|
||||
const flips = ref<BoardFlipEvent[]>([])
|
||||
let snapshot: Map<string, number> | null = null
|
||||
|
||||
function diffFlips(fresh: BoardOverviewRow[]) {
|
||||
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
const next = new Map<string, number>()
|
||||
const names = new Map<string, string>()
|
||||
for (const r of fresh) {
|
||||
if (r.change_pct !== null && r.change_pct !== undefined) next.set(r.code, r.change_pct)
|
||||
names.set(r.code, r.name)
|
||||
}
|
||||
if (snapshot) {
|
||||
const events: BoardFlipEvent[] = []
|
||||
for (const [code, pct] of next) {
|
||||
const before = snapshot.get(code)
|
||||
if (before === undefined) continue
|
||||
if (before <= 0 && pct > 0) {
|
||||
events.push({ code, name: names.get(code) ?? code, type: 'up', time: now, change_pct: pct })
|
||||
} else if (before >= 0 && pct < 0) {
|
||||
events.push({ code, name: names.get(code) ?? code, type: 'down', time: now, change_pct: pct })
|
||||
}
|
||||
}
|
||||
if (events.length > 0) flips.value = [...events, ...flips.value].slice(0, 30)
|
||||
}
|
||||
snapshot = next
|
||||
}
|
||||
|
||||
// ── 工具行:视图模式 / 排序 / 搜索 / 分类切换 ─────────────────────────────────
|
||||
|
||||
type SortKey = 'change_pct' | 'speed' | 'chg_3d' | 'chg_5d' | 'chg_20d' | 'chg_ytd'
|
||||
|
||||
const viewMode = ref<'tiles' | 'table'>(
|
||||
localStorage.getItem('board.viewMode') === 'table' ? 'table' : 'tiles',
|
||||
)
|
||||
function setViewMode(m: 'tiles' | 'table') {
|
||||
viewMode.value = m
|
||||
localStorage.setItem('board.viewMode', m)
|
||||
}
|
||||
|
||||
const sortKey = ref<SortKey>('change_pct')
|
||||
const sortOptions: Array<{ value: SortKey; label: string }> = [
|
||||
{ value: 'change_pct', label: '涨跌幅' },
|
||||
{ value: 'speed', label: '涨速' },
|
||||
{ value: 'chg_3d', label: '3日' },
|
||||
{ value: 'chg_5d', label: '5日' },
|
||||
{ value: 'chg_20d', label: '20日' },
|
||||
{ value: 'chg_ytd', label: '年初至今' },
|
||||
]
|
||||
|
||||
const search = ref('')
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return rows.value
|
||||
return rows.value.filter(
|
||||
(r) => r.name.toLowerCase().includes(q) || r.code.includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
const key = sortKey.value
|
||||
return [...filteredRows.value].sort((a, b) => {
|
||||
const av = a[key]
|
||||
const bv = b[key]
|
||||
if (av === null || av === undefined) return 1 // null 沉底
|
||||
if (bv === null || bv === undefined) return -1
|
||||
return bv - av
|
||||
})
|
||||
})
|
||||
|
||||
function setLevel(t: 'HY' | 'HY2') {
|
||||
if (activeType.value === t) return
|
||||
activeType.value = t
|
||||
resetAndReload()
|
||||
}
|
||||
|
||||
function resetAndReload() {
|
||||
rows.value = []
|
||||
flips.value = []
|
||||
snapshot = null
|
||||
loadOverview()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.boardType,
|
||||
(t) => {
|
||||
activeType.value = t
|
||||
search.value = ''
|
||||
resetAndReload()
|
||||
},
|
||||
)
|
||||
|
||||
// ── 轮询调度(同 Dashboard:交易时段门控 + 页面隐藏暂停) ──────────────────────
|
||||
|
||||
let timer = 0
|
||||
let sessionTimer = 0
|
||||
|
||||
const sessionGated = ref(localStorage.getItem('board.sessionGated') !== '0')
|
||||
function onSessionToggle() {
|
||||
localStorage.setItem('board.sessionGated', sessionGated.value ? '1' : '0')
|
||||
}
|
||||
|
||||
function isTradeSession(now = new Date()): boolean {
|
||||
const day = now.getDay()
|
||||
if (day === 0 || day === 6) return false
|
||||
const m = now.getHours() * 60 + now.getMinutes()
|
||||
return (m >= 555 && m <= 690) || (m >= 780 && m <= 905)
|
||||
}
|
||||
|
||||
const inSession = ref(isTradeSession())
|
||||
const autoPaused = computed(() => sessionGated.value && !inSession.value)
|
||||
const sessionLabel = computed(() => {
|
||||
if (!sessionGated.value) return '全天候模式'
|
||||
return inSession.value ? '交易中' : '休市 · 自动刷新已暂停'
|
||||
})
|
||||
|
||||
function tick() {
|
||||
inSession.value = isTradeSession()
|
||||
if (autoPaused.value || document.hidden) return
|
||||
loadOverview()
|
||||
loadStat()
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
loadOverview()
|
||||
loadStat()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshAll()
|
||||
timer = window.setInterval(tick, 30_000)
|
||||
sessionTimer = window.setInterval(() => {
|
||||
inSession.value = isTradeSession()
|
||||
}, 60_000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.clearInterval(timer)
|
||||
window.clearInterval(sessionTimer)
|
||||
})
|
||||
|
||||
// ── 弹窗(板块详情) ─────────────────────────────────────────────────────────
|
||||
|
||||
const boardDialog = ref<{ code: string; name: string } | null>(null)
|
||||
|
||||
function openBoard(r: BoardOverviewRow) {
|
||||
boardDialog.value = { code: r.code, name: r.name }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="board-view">
|
||||
<div class="view-head">
|
||||
<h2>{{ title }}<span v-if="titleSub" class="dim head-sub">{{ titleSub }}</span></h2>
|
||||
<template v-if="isIndustry">
|
||||
<button class="chip" :class="{ on: activeType === 'HY' }" @click="setLevel('HY')">一级</button>
|
||||
<button class="chip" :class="{ on: activeType === 'HY2' }" @click="setLevel('HY2')">二级</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<BoardStatStrip :rows="rows" :stat="stat" />
|
||||
|
||||
<!-- 工具行 -->
|
||||
<div class="toolbar">
|
||||
<span class="seg">
|
||||
<button :class="{ on: viewMode === 'tiles' }" @click="setViewMode('tiles')">热力图</button>
|
||||
<button :class="{ on: viewMode === 'table' }" @click="setViewMode('table')">表格</button>
|
||||
</span>
|
||||
<label class="tb-label">排序
|
||||
<select v-model="sortKey">
|
||||
<option v-for="o in sortOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<input v-model="search" class="search" type="text" :placeholder="isIndustry ? '搜索行业名/代码' : '搜索概念名/代码'" />
|
||||
<span class="tb-spacer"></span>
|
||||
<span class="dot" :class="{ live: !autoPaused }"></span>
|
||||
<span class="session-label" :class="{ paused: autoPaused }">{{ sessionLabel }}</span>
|
||||
<label class="session-toggle">
|
||||
<input v-model="sessionGated" type="checkbox" @change="onSessionToggle" />
|
||||
仅交易时段刷新
|
||||
</label>
|
||||
<span v-if="lastRefresh" class="dim refresh-ts">{{ lastRefresh }}</span>
|
||||
<button class="manual-refresh" @click="refreshAll">↻ 刷新</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="err card">
|
||||
加载失败:{{ error }}
|
||||
<button @click="refreshAll">重试</button>
|
||||
</div>
|
||||
<div v-else-if="loading" class="loading">板块数据加载中…</div>
|
||||
|
||||
<!-- 主区 + 右栏 -->
|
||||
<div v-show="!error && !loading" class="content">
|
||||
<div class="main-area card">
|
||||
<BoardTiles
|
||||
v-if="viewMode === 'tiles'"
|
||||
:rows="sortedRows"
|
||||
:tile-min-width="isIndustry ? 104 : 88"
|
||||
@select="openBoard"
|
||||
/>
|
||||
<table v-else class="qtable board-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>代码</th>
|
||||
<th>最新价</th>
|
||||
<th class="sortable" :class="{ on: sortKey === 'change_pct' }" @click="sortKey = 'change_pct'">
|
||||
涨跌幅{{ sortKey === 'change_pct' ? ' ▾' : '' }}
|
||||
</th>
|
||||
<th class="sortable" :class="{ on: sortKey === 'speed' }" @click="sortKey = 'speed'">
|
||||
涨速{{ sortKey === 'speed' ? ' ▾' : '' }}
|
||||
</th>
|
||||
<th class="sortable" :class="{ on: sortKey === 'chg_3d' }" @click="sortKey = 'chg_3d'">
|
||||
3日{{ sortKey === 'chg_3d' ? ' ▾' : '' }}
|
||||
</th>
|
||||
<th class="sortable" :class="{ on: sortKey === 'chg_5d' }" @click="sortKey = 'chg_5d'">
|
||||
5日{{ sortKey === 'chg_5d' ? ' ▾' : '' }}
|
||||
</th>
|
||||
<th class="sortable" :class="{ on: sortKey === 'chg_20d' }" @click="sortKey = 'chg_20d'">
|
||||
20日{{ sortKey === 'chg_20d' ? ' ▾' : '' }}
|
||||
</th>
|
||||
<th class="sortable" :class="{ on: sortKey === 'chg_ytd' }" @click="sortKey = 'chg_ytd'">
|
||||
YTD{{ sortKey === 'chg_ytd' ? ' ▾' : '' }}
|
||||
</th>
|
||||
<th>领涨股</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in sortedRows" :key="r.code" class="clickable" @click="openBoard(r)">
|
||||
<td class="name-cell">{{ r.name }}</td>
|
||||
<td class="dim">{{ r.code }}</td>
|
||||
<td class="mono">{{ fmt2(r.price) }}</td>
|
||||
<td class="mono" :class="dirClass(r.change_pct)">{{ fmtPctSigned(r.change_pct) }}</td>
|
||||
<td class="mono" :class="dirClass(r.speed)">{{ fmtPctSigned(r.speed) }}</td>
|
||||
<td class="mono" :class="dirClass(r.chg_3d)">{{ fmtPctSigned(r.chg_3d) }}</td>
|
||||
<td class="mono" :class="dirClass(r.chg_5d)">{{ fmtPctSigned(r.chg_5d) }}</td>
|
||||
<td class="mono" :class="dirClass(r.chg_20d)">{{ fmtPctSigned(r.chg_20d) }}</td>
|
||||
<td class="mono" :class="dirClass(r.chg_ytd)">{{ fmtPctSigned(r.chg_ytd) }}</td>
|
||||
<td class="leader-cell">
|
||||
<span v-if="r.leader_name">{{ r.leader_name }}</span>
|
||||
<span class="mono" :class="dirClass(r.leader_change_pct)">{{ fmtPctSigned(r.leader_change_pct) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="viewMode === 'table' && sortedRows.length === 0" class="loading">没有匹配的板块</div>
|
||||
</div>
|
||||
<BoardRankRail class="rail-area" :rows="rows" :flips="flips" @select="openBoard" />
|
||||
</div>
|
||||
|
||||
<BoardDialog
|
||||
v-if="boardDialog"
|
||||
:code="boardDialog.code"
|
||||
:name="boardDialog.name"
|
||||
@close="boardDialog = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board-view {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
/* flex 列内子项按内容占位,禁止被压缩(容器超高时靠 overflow-y 滚动) */
|
||||
.view-head,
|
||||
.toolbar,
|
||||
.stat-strip,
|
||||
.err,
|
||||
.loading {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.view-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.view-head h2 {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.head-sub {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.chip {
|
||||
padding: 2px 10px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.chip.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(74, 158, 255, 0.12);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
}
|
||||
.seg button {
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.seg button:first-child {
|
||||
border-radius: var(--radius) 0 0 var(--radius);
|
||||
}
|
||||
.seg button:last-child {
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
margin-left: -1px;
|
||||
}
|
||||
.seg button.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(74, 158, 255, 0.12);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.tb-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tb-label select {
|
||||
width: auto;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.search {
|
||||
width: 180px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tb-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-dim);
|
||||
}
|
||||
.dot.live {
|
||||
background: var(--up);
|
||||
box-shadow: 0 0 4px var(--up);
|
||||
}
|
||||
.session-label {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.session-label.paused {
|
||||
color: var(--warn);
|
||||
}
|
||||
.session-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.session-toggle input {
|
||||
width: auto;
|
||||
}
|
||||
.refresh-ts {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.manual-refresh {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.err {
|
||||
color: var(--up);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.loading {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.main-area {
|
||||
padding: 10px;
|
||||
min-height: 300px;
|
||||
}
|
||||
.board-table th.sortable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.board-table th.sortable:hover,
|
||||
.board-table th.sortable.on {
|
||||
color: var(--accent);
|
||||
}
|
||||
.board-table td.name-cell {
|
||||
font-weight: 600;
|
||||
}
|
||||
.board-table .leader-cell span:first-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user