feat: 行业/概念总览 — WebUI 板块总览双栏目(统计条+热力图/表格+三榜/翻红翻绿+板块弹窗复用)+ /board-mac/overview 聚合端点(多排序归并+15s缓存)

This commit is contained in:
GitHub
2026-09-04 15:59:57 +08:00
parent d6478a1e1c
commit f6560f3c6d
11 changed files with 1587 additions and 0 deletions
+273
View File
@@ -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` | 低(12 页/次) |
| `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 色块,不用 ECharts500 块内 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/treemaptreemap 未注册、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_* | 点领涨股 → StockDialogstopPropagation |
| 主力净额 | `/board-mac/ranking` 合并(懒加载,见 3.7) | 可空 |
| ★ | 自选 | 复用 watchlist APIP2 |
表格排序纯前端(数据已全量在内存),不再发请求。行业 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 | ~270500 |
| 二级切换 | 一级(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 档:00.5% / 0.51 / 12 / 23 / >3%。
- **弹窗层级**:与首页一致(BoardDialog teleport to body1280px 遮罩弹窗);成分股→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(恒 0Issue #53)。
5. **刷新风暴**TTL 缓存 + visibilitychange 暂停 + 30s 间隔,三重保护。
## 7. 分期计划
| 期 | 内容 | 预估 |
|---|---|---|
| **P1MVP** | 导航+路由、BoardOverviewView(热力图+表格+搜索)、统计条、右栏涨幅/跌幅/涨速榜、翻红翻绿时间线、30s 自动刷新、BoardDialog 复用、`/board-mac/overview` 端点+缓存+单测 | 1.52 天 |
| **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 服务器时出现错误条 + 重试,页面不白屏
+101
View File
@@ -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)
+221
View File
@@ -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
+2
View File
@@ -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>
+15
View File
@@ -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) })
+166
View File
@@ -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>
+171
View File
@@ -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>
+106
View File
@@ -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>
+4
View File
@@ -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 },
+40
View File
@@ -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 {
+488
View File
@@ -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>