Compare commits
3 Commits
bed9cab437
...
fe6d2b4fe5
| Author | SHA1 | Date | |
|---|---|---|---|
| fe6d2b4fe5 | |||
| 21ec0b2353 | |||
| e2037f48f0 |
@@ -0,0 +1,5 @@
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "核对 feature 边界、URL 状态和质量门禁。"}
|
||||
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "核对 Tab 语义、表格列对齐、滚动、状态提示和可访问性。"}
|
||||
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "核对双榜 query key、取消信号、分页停止条件和错误状态归属。"}
|
||||
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "核对测试覆盖以及 format、lint、typecheck、test、build 结果。"}
|
||||
{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "确认未把原站未知的评分、涨跌幅或在榜天数伪造成已实现字段。"}
|
||||
@@ -0,0 +1,39 @@
|
||||
# 板块资金雷达双榜交互技术设计
|
||||
|
||||
## 范围与边界
|
||||
|
||||
本轮只修改 `zhixing-web` 的板块资金雷达页面、相关 query 组合和测试,不改变 FastAPI 参数、响应模型、数据库表或指标构建逻辑。后端现有 `GET /api/v1/sector-radar/rankings` 已支持 `side=top` 与 `side=bottom`,前端用两条独立无限查询组合双榜;两侧仍共享交易日、板块类型、指标视角、排名变化参数、搜索词和 `page_size`。
|
||||
|
||||
路由继续兼容既有 `side` 查询参数,但双榜页面不再用它决定可见榜单,也不提供“榜单范围”控件。旧链接无论携带 `side=all/top/bottom` 都进入同一双榜视图,避免为本轮视觉迭代扩大路由类型迁移范围。
|
||||
|
||||
## Tab 与工具栏
|
||||
|
||||
板块类型和指标视角改为 feature 内的语义化按钮 Tab,使用 `role="tablist"`、`role="tab"` 与 `aria-selected`,沿用现有 `updateSearch` 写回 URL,并在切换时重置 `page=1`。不新增共享 Tabs primitive或第三方依赖;交易日、排名变化指标和对比区间继续使用现有 Select,搜索继续使用 Input。
|
||||
|
||||
指标 Tab 顺序按目标截图组织为波段资金率、单日资金率、单日净额、排名变化。排名变化激活时,附属的“变化指标”和“对比区间”控件紧凑显示,不改变主 Tab 层级。
|
||||
|
||||
## 双查询与滚动数据流
|
||||
|
||||
页面从同一基础筛选分别构造 `side="top"` 和 `side="bottom"` 的 `RadarRankingsQuery`,调用两次现有 `useSectorRadarRankings`。现有 query key 已包含 `side` 且排除 `page`,因此两侧缓存和分页相互独立,无需修改后端契约。
|
||||
|
||||
每侧分别展平 `InfiniteData.pages`,按数组索引把强榜第 N 行和弱榜第 N 行配成一个视觉行。任一侧行数不足时对应单元格留空,另一侧继续正常展示。唯一滚动容器接近底部时,只为仍有下一页、未请求中且未处于加载更多错误的侧调用 `fetchNextPage`;请求级互斥覆盖同一滚动周期,避免连续事件重复取数。两侧都没有下一页时停止。
|
||||
|
||||
加载更多失败按侧记录并保留已加载行;提示区说明强榜或弱榜哪一侧失败,并只重试失败侧。首次请求、后台刷新、刷新失败、部分发布、无发布和搜索无匹配继续保持明确分支,不用一侧的下一页错误覆盖另一侧现有数据。
|
||||
|
||||
## 镜像表格结构
|
||||
|
||||
使用一张语义化 `<table>` 和显式列宽布局承载左右镜像双榜。第一层表头分为“资金进攻榜 TOP 10%”、当前指标和“BOTTOM 10% 资金撤离榜”;第二层列从左到右为:
|
||||
|
||||
`排名 / 板块 | 排名百分位 | 样本 | 资金覆盖率 | 质量 | 流入 | 流出 | 质量 | 资金覆盖率 | 样本 | 排名百分位 | 排名 / 板块`
|
||||
|
||||
右侧文本和列标题采用与阅读方向匹配的对齐方式,中间两列增加稳定分隔线。表头继续 sticky,表格保留足够 `min-width`,窄视口通过单一横向滚动容器查看,不压缩成多行或错位结构。
|
||||
|
||||
普通资金指标使用 `metric_value`:`CNY_100M` 以亿元格式化,`ratio` 以百分比格式化;强榜显示显式流入方向,弱榜显示显式流出方向,并使用现有主题中的红/绿语义和低饱和背景条增强对比。排名变化视角中间列改为“排名上升 / 排名下降”,值使用 `rank_change`,参考指标不伪装成独立流入/流出字段。
|
||||
|
||||
## 延期字段
|
||||
|
||||
涨跌幅、加权评分和在榜天数明确延期。当前 ranking row 只有单一 `metric_value`、排名、百分位、样本、覆盖率、质量与 1—5 日 `rank_change`;虽然数据源局部出现涨跌幅,聚合和排名表并未持久化该字段,加权评分公式也未公开,在榜天数没有历史轨迹契约。后续讨论必须先定义三项字段的业务语义、point-in-time 输入、公式、历史深度、异常处理与迁移兼容,不能在本轮 UI 中用现有字段代替或推算。
|
||||
|
||||
## 兼容、验证与回滚
|
||||
|
||||
本轮不改 HTTP 与数据库,可回滚为原单查询表格而不影响数据。验证包括 query 双实例参数、Tab 的 URL 更新、双榜配对、两侧独立续页、一侧结束或失败时另一侧继续、表头/行列数一致、正负格式化、可访问性、完整前端质量门禁和真实浏览器桌面布局检查。
|
||||
@@ -0,0 +1,5 @@
|
||||
{"file": ".trellis/spec/frontend/index.md", "reason": "确认本轮属于 sector-radar feature,遵守前端分层、URL 状态与质量门禁。"}
|
||||
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Tab、镜像表格、状态提示和滚动容器需遵守现有组件、Tailwind 与可访问性约定。"}
|
||||
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "双榜继续由 TanStack Query 管理两侧独立无限查询、缓存和取消信号。"}
|
||||
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "实现需补齐用户可见行为测试并通过完整前端质量命令。"}
|
||||
{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "提供原站四种指标、TOP/BOTTOM 分层、公开字段和未知评分公式的证据边界。"}
|
||||
@@ -0,0 +1,33 @@
|
||||
# 板块资金雷达双榜交互执行计划
|
||||
|
||||
## 执行清单
|
||||
|
||||
- [ ] 更新页面筛选区,将板块类型和指标视角改为语义化 Tab;移除榜单范围 Select,保留交易日、搜索和排名变化附属控制,并验证 URL 状态重置。
|
||||
- [ ] 从共享筛选构造强榜与弱榜两条无限查询,分别展平分页结果,组合双侧加载、刷新、错误和重试状态。
|
||||
- [ ] 将单侧 `RadarTable` 重构为左右镜像双榜表格,建立稳定列宽、双层 sticky 表头、中间流入/流出(排名变化时为排名升降)以及双侧空位规则。
|
||||
- [ ] 为净额、比例和排名变化实现方向明确的格式化与低饱和条形表达,保留质量、覆盖率和样本等知行已有字段,不实现延期字段。
|
||||
- [ ] 更新页面行为测试,覆盖 Tab、双查询参数、行配对、双侧分页、一侧提前结束、一侧加载失败重试、格式化语义以及旧分页/榜单范围控件消失。
|
||||
- [ ] 运行相关 Vitest、`pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build` 和 `git diff --check`,随后在真实浏览器中检查桌面布局、滚动加载和控制台。
|
||||
|
||||
## 重点文件
|
||||
|
||||
- `zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx`
|
||||
- `zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx`
|
||||
- 如双侧组合需要抽取 query helper,再最小修改 `zhixing-web/src/features/sector-radar/api/sector-radar.query.ts` 及其测试;不修改后端、迁移和共享 Pagination。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
cd zhixing-web
|
||||
pnpm exec vitest run src/features/sector-radar/api/sector-radar.query.test.ts src/features/sector-radar/pages/sector-radar-page.test.tsx
|
||||
pnpm check
|
||||
pnpm build
|
||||
cd ..
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 回滚点与风险
|
||||
|
||||
双榜由两条独立 HTTP 请求组成,不保证同一数据库事务快照;两条请求使用同一交易日与查询条件,且排名发布不可变时结果一致。若后续服务允许发布过程中变更,需要再引入 publication id 固定或后端双榜原子响应,本轮不扩大契约。
|
||||
|
||||
镜像表格列数多,桌面窄视口不可避免横向滚动;验收重点是列宽与表头稳定、文本不换行和单一滚动容器,而不是压缩所有列到任意宽度。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 板块资金雷达双榜交互对齐
|
||||
|
||||
## Goal
|
||||
|
||||
把知行项目的板块资金雷达从“筛选器 + 单边普通表格”进一步对齐 OneChartLab 原站的核心浏览方式,让用户在同一屏内通过 Tab 切换板块类型和排序指标,并横向对照强榜与弱榜的流入、流出表现。
|
||||
|
||||
用户价值是减少筛选与翻页操作,在一个连续滚动榜单中直接比较资金进入端和撤离端,同时保持现有知行独立指标及数据质量边界。
|
||||
|
||||
## Background
|
||||
|
||||
- 当前页面已经支持服务端分页上的无限滚动,但仍以 Select 选择板块类型、指标视角和榜单范围,并只展示单侧榜单。
|
||||
- 用户提供的目标截图显示:搜索框后使用“概念 / 行业”Tab,右侧使用指标 Tab;主体表格同时呈现左侧强榜和右侧弱榜,中间用“流入 / 流出”两列形成视觉对照。
|
||||
- 本轮不使用 `lark-design-prototype`,以用户截图、现有项目视觉体系和实际数据契约为准。
|
||||
|
||||
## Requirements
|
||||
|
||||
- 将“概念板块 / 行业板块”从 Select 改为可直接点击的 Tab,并与 URL 状态、数据查询及刷新行为保持同步。
|
||||
- 将指标视角改为 Tab 交互,至少覆盖现有的波段资金率、单日资金率、单日净额和排名变化;排名变化所需的指标与对比区间仍需有紧凑的附属控制。
|
||||
- 强榜与弱榜必须在同一个连续滚动列表中成对展示,不再要求用户通过“榜单范围”筛选器切换。
|
||||
- 表头结构、左右方向和列对齐参照目标截图:左侧为资金进攻榜,右侧为资金撤离榜,中间明确区分流入和流出;强榜从左向中间阅读,弱榜从中间向右阅读。
|
||||
- 双榜外围只使用知行已有且可验证的排名百分位、样本、资金覆盖率和质量字段;左右按镜像顺序排列,并以固定列宽或等价布局保证表头和行数据对齐。
|
||||
- 流入和流出数值使用方向明确、可比较的正负与色彩表达,并保持文本语义和可访问性,不只依赖颜色区分。
|
||||
- 波段资金率、单日资金率和单日净额视角的中间列使用“流入 / 流出”;排名变化视角使用与排名升降一致的表头和数值语义,不把排名变化伪装成资金流量。
|
||||
- 保留表头吸附、连续滚动加载、首次加载、后台刷新、部分发布、加载更多失败重试、空数据和致命错误等现有行为。
|
||||
- 不新增与目标交互无关的大块说明卡、指标墙或辅助面板。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 用户可通过“概念 / 行业”Tab 切换板块类型,激活状态清晰且 URL 查询状态同步更新。
|
||||
- [ ] 用户可通过指标 Tab 切换波段资金率、单日资金率、单日净额和排名变化,当前指标与中间流入/流出表头一致。
|
||||
- [ ] 每个可见数据行同时展示一个强榜板块和一个弱榜板块,左右榜单分别保持各自排名顺序。
|
||||
- [ ] 表头和数据列在桌面视口下稳定对齐,左右镜像列均展示排名/板块、排名百分位、样本、资金覆盖率和质量,中间流入/流出区域与两侧之间有明确边界,横向滚动时结构不塌陷。
|
||||
- [ ] 中间指标值按当前视角正确格式化:净额以亿元显示,比例以百分比显示,排名变化以升降名次数显示;强弱方向有显式正负号或文字语义。
|
||||
- [ ] 滚动接近底部时强榜与弱榜继续加载下一批,任一侧提前结束时另一侧仍可继续展示,直到两侧都加载完成。
|
||||
- [ ] 加载更多失败时已加载的两侧数据不丢失,并提供可操作的重试入口。
|
||||
- [ ] 不再显示“榜单范围”Select、底部分页器或大块纯描述卡。
|
||||
- [ ] 相关前后端契约测试、前端行为测试、格式检查、lint、类型检查、完整测试和构建通过,并完成真实浏览器交互与布局检查。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 复刻原站未在知行数据契约中存在的股票勾选、加权评分或自选功能。
|
||||
- 更改板块指标公式、排序定义、数据构建任务或历史数据口径。
|
||||
- 引入与本轮交互无关的新页面模块。
|
||||
|
||||
## Deferred Items
|
||||
|
||||
- 原站外围的“涨跌幅、加权评分、在榜天数”本轮不实现。当前知行排名持久化与 HTTP row 均没有这三个字段,其中加权评分的公开证据不足以确认公式;后续需分别讨论指标语义、输入时点、计算公式、持久化和历史兼容后再立项。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "sector-radar-dual-ranking",
|
||||
"name": "sector-radar-dual-ranking",
|
||||
"title": "板块资金雷达双榜交互对齐",
|
||||
"description": "",
|
||||
"status": "completed",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "yuxuanhui",
|
||||
"assignee": "yuxuanhui",
|
||||
"createdAt": "2026-09-01",
|
||||
"completedAt": "2026-09-01",
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 13
|
||||
- **Total Sessions**: 14
|
||||
- **Last Active**: 2026-09-01
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~335 | Active |
|
||||
| `journal-1.md` | ~356 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@@ -29,6 +29,7 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits | Branch |
|
||||
|---|------|-------|---------|--------|
|
||||
| 14 | 2026-09-01 | 完成板块资金雷达双榜交互对齐 | `744cb76` | `develop` |
|
||||
| 13 | 2026-09-01 | 板块资金雷达滚动加载迭代 | `be31235`, `5f34be5` | `develop` |
|
||||
| 12 | 2026-08-31 | 集成知行 B1 FastDTW 图形评分 | `6ce291e`, `5800661` | `codex/point` |
|
||||
| 11 | 2026-08-31 | 资金雷达当前上市股票资金流补拉 | `2ffd016` | `codex/sector-radar-listed-moneyflow-recovery` |
|
||||
|
||||
@@ -333,3 +333,24 @@
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
|
||||
## Session 14: 完成板块资金雷达双榜交互对齐
|
||||
|
||||
**Date**: 2026-09-01
|
||||
**Task**: 完成板块资金雷达双榜交互对齐
|
||||
**Branch**: `develop`
|
||||
|
||||
### Summary
|
||||
|
||||
实现概念/行业与指标 Tab、强弱榜双无限查询和镜像 12 列表格,补齐独立分页/失败重试、方向格式化与可访问性;通过前端 format、lint、typecheck、77 项测试和 build,并完成浏览器路由与 Tab 冒烟检查。
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `744cb76` | (see git log) |
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
@@ -115,6 +115,18 @@ const rankingsResponse: RadarRankingsResponse = {
|
||||
],
|
||||
}
|
||||
|
||||
const bottomRankingsResponse: RadarRankingsResponse = {
|
||||
...rankingsResponse,
|
||||
side: "bottom",
|
||||
rows: rankingsResponse.rows.map((row, index) => ({
|
||||
...row,
|
||||
sector_code: `BOTTOM-${index + 1}`,
|
||||
sector_name: index === 0 ? "新能源" : "医药商业",
|
||||
metric_value: index === 0 ? -8.4 : null,
|
||||
rank_position: index + 1,
|
||||
})),
|
||||
}
|
||||
|
||||
function rankingQueryResult(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
data: { pageParams: [1], pages: [rankingsResponse] },
|
||||
@@ -161,10 +173,10 @@ describe("SectorRadarPage", () => {
|
||||
it("renders ranking rows without the description card or pagination", () => {
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
expect(screen.getByText("12.5 亿元")).toBeInTheDocument()
|
||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
||||
expect(screen.getByText("样本有限")).toBeInTheDocument()
|
||||
expect(screen.getByText("95%")).toBeInTheDocument()
|
||||
expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("样本有限")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("95%")[0]).toBeInTheDocument()
|
||||
expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"),
|
||||
@@ -197,6 +209,38 @@ describe("SectorRadarPage", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("renders accessible tabs, writes tab state to the URL, and queries both sides", () => {
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
expect(
|
||||
screen.getByRole("tablist", { name: "板块类型" }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole("tablist", { name: "指标视角" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole("tab", { name: "单日净额" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
expect(useSectorRadarRankings).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ side: "top", view: "amount" }),
|
||||
)
|
||||
expect(useSectorRadarRankings).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ side: "bottom", view: "amount" }),
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "行业" }))
|
||||
const searchCall = navigate.mock.calls[0]?.[0].search as (
|
||||
previous: SectorRadarRouteSearch,
|
||||
) => SectorRadarRouteSearch
|
||||
expect(searchCall({ ...routeSearch, page: 4 })).toMatchObject({
|
||||
page: 1,
|
||||
sectorType: "industry",
|
||||
})
|
||||
})
|
||||
|
||||
it("loads the next page once when repeated scroll events reach the bottom", () => {
|
||||
fetchNextPage.mockReturnValue(new Promise(() => undefined))
|
||||
useSectorRadarRankings.mockReturnValue(
|
||||
@@ -230,7 +274,7 @@ describe("SectorRadarPage", () => {
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("正在加载更多板块")
|
||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
||||
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||
expect(screen.queryByText("雷达数据正在刷新")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -257,9 +301,9 @@ describe("SectorRadarPage", () => {
|
||||
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
||||
expect(screen.getByText("低空经济")).toBeInTheDocument()
|
||||
expect(screen.getByText("商业航天")).toBeInTheDocument()
|
||||
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("低空经济")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("商业航天")[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("keeps loaded rows and offers retry when loading more fails", () => {
|
||||
@@ -273,15 +317,44 @@ describe("SectorRadarPage", () => {
|
||||
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
||||
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"加载更多板块失败,已加载结果仍保留",
|
||||
"强榜加载更多失败,弱榜加载更多失败,已加载结果仍保留",
|
||||
)
|
||||
expect(screen.queryByText("雷达数据刷新失败")).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试加载更多" }))
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试强榜" }))
|
||||
expect(fetchNextPage).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("keeps the healthy side visible when only the other side fails to load more", () => {
|
||||
const topFetchNextPage = vi.fn().mockResolvedValue(undefined)
|
||||
const bottomFetchNextPage = vi.fn().mockResolvedValue(undefined)
|
||||
useSectorRadarRankings.mockImplementation((query: { side: string }) =>
|
||||
rankingQueryResult({
|
||||
data: {
|
||||
pageParams: [1],
|
||||
pages: [
|
||||
query.side === "bottom" ? bottomRankingsResponse : rankingsResponse,
|
||||
],
|
||||
},
|
||||
fetchNextPage:
|
||||
query.side === "bottom" ? bottomFetchNextPage : topFetchNextPage,
|
||||
isFetchNextPageError: query.side === "bottom",
|
||||
hasNextPage: true,
|
||||
}),
|
||||
)
|
||||
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||
expect(screen.getByText("新能源")).toBeInTheDocument()
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("弱榜加载更多失败")
|
||||
expect(screen.getByRole("button", { name: "重试弱榜" })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试弱榜" }))
|
||||
expect(bottomFetchNextPage).toHaveBeenCalledOnce()
|
||||
expect(topFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("shows rank changes without inventing missing history", () => {
|
||||
routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 }
|
||||
useSectorRadarRankings.mockReturnValue(
|
||||
@@ -307,10 +380,10 @@ describe("SectorRadarPage", () => {
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "5 日排名变化" }),
|
||||
screen.getByRole("columnheader", { name: "排名上升" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("+3")).toBeInTheDocument()
|
||||
expect(screen.getByText("暂无可比历史")).toBeInTheDocument()
|
||||
expect(screen.getAllByText("+3 名")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("暂无可比历史")[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("warns when a partial attempt has not replaced last-good", () => {
|
||||
@@ -350,7 +423,7 @@ describe("SectorRadarPage", () => {
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"当前继续展示上一次已读取的有效结果",
|
||||
)
|
||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
||||
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("keeps stale data visible when a background refresh fails", () => {
|
||||
@@ -361,10 +434,38 @@ describe("SectorRadarPage", () => {
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败")
|
||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
||||
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||
expect(screen.queryByText("板块资金雷达暂时不可用")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows a fatal error when one side has no data and the other has no response", () => {
|
||||
useSectorRadarRankings.mockImplementation((query: { side: string }) =>
|
||||
rankingQueryResult(
|
||||
query.side === "top"
|
||||
? {
|
||||
data: {
|
||||
pageParams: [1],
|
||||
pages: [
|
||||
{
|
||||
...rankingsResponse,
|
||||
side: "top",
|
||||
status: "no_data",
|
||||
publication: null,
|
||||
rows: [],
|
||||
total: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: { data: undefined, isError: true },
|
||||
),
|
||||
)
|
||||
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
expect(screen.getByText("板块资金雷达暂时不可用")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders loading, error with retry, and no-data states", () => {
|
||||
useSectorRadarDates.mockReturnValue({
|
||||
data: undefined,
|
||||
@@ -408,7 +509,7 @@ describe("SectorRadarPage", () => {
|
||||
rerender(<SectorRadarPage />)
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试" }))
|
||||
expect(refetchDates).toHaveBeenCalledOnce()
|
||||
expect(refetchRankings).toHaveBeenCalledOnce()
|
||||
expect(refetchRankings).toHaveBeenCalledTimes(2)
|
||||
|
||||
useSectorRadarDates.mockReturnValue({
|
||||
data: {
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
useSectorRadarRankings,
|
||||
} from "@/features/sector-radar/api/sector-radar.query"
|
||||
import type {
|
||||
RadarMetricKind,
|
||||
RadarMetricQuality,
|
||||
RadarMetricUnit,
|
||||
RadarPublication,
|
||||
RadarRankingRow,
|
||||
RadarRankingsQuery,
|
||||
RadarRankingsResponse,
|
||||
RadarView,
|
||||
SectorRadarRouteSearch,
|
||||
} from "@/features/sector-radar/api/sector-radar.types"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
@@ -37,29 +39,20 @@ import {
|
||||
import { Skeleton } from "@/shared/ui/skeleton"
|
||||
|
||||
const sectorTypeOptions = [
|
||||
{ label: "概念板块", value: "concept" },
|
||||
{ label: "行业板块", value: "industry" },
|
||||
{ label: "概念", value: "concept" },
|
||||
{ label: "行业", value: "industry" },
|
||||
] as const
|
||||
|
||||
const viewOptions = [
|
||||
{ label: "主力净额", value: "amount" },
|
||||
{ label: "波段资金率", value: "swing" },
|
||||
{ label: "单日资金率", value: "ratio" },
|
||||
{ label: "3—10 日波段资金率", value: "swing" },
|
||||
{ label: "单日净额", value: "amount" },
|
||||
{ label: "排名变化", value: "rank_change" },
|
||||
] as const
|
||||
|
||||
const metricOptions = [
|
||||
{ label: "主力净额", value: "amount" },
|
||||
{ label: "单日资金率", value: "ratio" },
|
||||
{ label: "3—10 日波段资金率", value: "swing" },
|
||||
{ label: "波段资金率", value: "swing" },
|
||||
] as const
|
||||
|
||||
const sideOptions = [
|
||||
{ label: "全部板块", value: "all" },
|
||||
{ label: "强榜", value: "top" },
|
||||
{ label: "弱榜", value: "bottom" },
|
||||
] as const
|
||||
|
||||
const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
|
||||
label: `${value} 日变化`,
|
||||
value: String(value),
|
||||
@@ -68,41 +61,56 @@ const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
|
||||
export function SectorRadarPage() {
|
||||
const search = useSearch({ from: "/_workspace/sector-radar" })
|
||||
const navigate = useNavigate({ from: "/sector-radar" })
|
||||
const query: RadarRankingsQuery = {
|
||||
const baseQuery: Omit<RadarRankingsQuery, "side"> = {
|
||||
tradeDate: search.tradeDate,
|
||||
sectorType: search.sectorType,
|
||||
view: search.view,
|
||||
rankChangeMetric: search.rankChangeMetric,
|
||||
rankChangeDays: search.rankChangeDays,
|
||||
side: search.side,
|
||||
search: search.search || undefined,
|
||||
page: search.page,
|
||||
pageSize: search.pageSize,
|
||||
}
|
||||
const dates = useSectorRadarDates()
|
||||
const rankings = useSectorRadarRankings(query)
|
||||
const rankingPages = rankings.data?.pages ?? []
|
||||
const firstRankingPage = rankingPages[0]
|
||||
const rankingRows = rankingPages.flatMap((page) => page.rows)
|
||||
|
||||
const topRankings = useSectorRadarRankings({ ...baseQuery, side: "top" })
|
||||
const bottomRankings = useSectorRadarRankings({
|
||||
...baseQuery,
|
||||
side: "bottom",
|
||||
})
|
||||
function updateSearch(next: Partial<SectorRadarRouteSearch>) {
|
||||
void navigate({ search: (previous) => ({ ...previous, ...next }) })
|
||||
}
|
||||
|
||||
const loading = dates.isPending || rankings.isPending
|
||||
const loading =
|
||||
dates.isPending || topRankings.isPending || bottomRankings.isPending
|
||||
const refreshing =
|
||||
!loading &&
|
||||
(Boolean(dates.isFetching) ||
|
||||
(Boolean(rankings.isFetching) && !rankings.isFetchingNextPage))
|
||||
(Boolean(topRankings.isFetching) && !topRankings.isFetchingNextPage) ||
|
||||
(Boolean(bottomRankings.isFetching) &&
|
||||
!bottomRankings.isFetchingNextPage))
|
||||
const topHasData = Boolean(topRankings.data)
|
||||
const bottomHasData = Boolean(bottomRankings.data)
|
||||
const topFirstPage = topRankings.data?.pages[0]
|
||||
const bottomFirstPage = bottomRankings.data?.pages[0]
|
||||
const topHasSuccess = topFirstPage?.status === "success"
|
||||
const bottomHasSuccess = bottomFirstPage?.status === "success"
|
||||
const fatalError =
|
||||
(dates.isError && !dates.data) || (rankings.isError && !rankings.data)
|
||||
Boolean(dates.isError && !dates.data) ||
|
||||
(!topHasData && !bottomHasData) ||
|
||||
(!topHasSuccess &&
|
||||
!bottomHasSuccess &&
|
||||
(topRankings.isError || bottomRankings.isError))
|
||||
const refreshFailed =
|
||||
!fatalError &&
|
||||
(Boolean(dates.isError) ||
|
||||
(Boolean(rankings.isError) && !rankings.isFetchNextPageError))
|
||||
(Boolean(topRankings.isError) && !topRankings.isFetchNextPageError) ||
|
||||
(Boolean(bottomRankings.isError) && !bottomRankings.isFetchNextPageError))
|
||||
const noData =
|
||||
dates.data?.status === "no_data" || firstRankingPage?.status === "no_data"
|
||||
|
||||
dates.data?.status === "no_data" ||
|
||||
(topFirstPage?.status === "no_data" &&
|
||||
bottomFirstPage?.status === "no_data")
|
||||
const response = topFirstPage ?? bottomFirstPage
|
||||
const publication = topFirstPage?.publication ?? bottomFirstPage?.publication
|
||||
return (
|
||||
<PageLayout
|
||||
actions={
|
||||
@@ -120,35 +128,30 @@ export function SectorRadarPage() {
|
||||
<RadarError
|
||||
onRetry={() => {
|
||||
void dates.refetch()
|
||||
void rankings.refetch()
|
||||
void topRankings.refetch()
|
||||
void bottomRankings.refetch()
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!loading && !fatalError && noData ? <RadarNoData /> : null}
|
||||
{!loading &&
|
||||
!fatalError &&
|
||||
!noData &&
|
||||
dates.data &&
|
||||
firstRankingPage?.status === "success" &&
|
||||
firstRankingPage.publication ? (
|
||||
{!loading && !fatalError && !noData && response && publication ? (
|
||||
<>
|
||||
{refreshFailed ? (
|
||||
<RadarRefreshFailureStatus />
|
||||
) : refreshing ? (
|
||||
<RadarRefreshStatus />
|
||||
) : null}
|
||||
<RadarPublicationStatus
|
||||
currentAttempt={dates.data.current_attempt}
|
||||
lastGood={dates.data.last_good}
|
||||
publication={firstRankingPage.publication}
|
||||
/>
|
||||
{dates.data ? (
|
||||
<RadarPublicationStatus
|
||||
currentAttempt={dates.data.current_attempt}
|
||||
lastGood={dates.data.last_good}
|
||||
publication={publication}
|
||||
/>
|
||||
) : null}
|
||||
<RadarTable
|
||||
hasNextPage={Boolean(rankings.hasNextPage)}
|
||||
isFetchNextPageError={rankings.isFetchNextPageError}
|
||||
isFetchingNextPage={rankings.isFetchingNextPage}
|
||||
onLoadMore={() => rankings.fetchNextPage()}
|
||||
response={firstRankingPage}
|
||||
rows={rankingRows}
|
||||
bottom={bottomRankings}
|
||||
response={response}
|
||||
top={topRankings}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
@@ -157,18 +160,19 @@ export function SectorRadarPage() {
|
||||
)
|
||||
}
|
||||
|
||||
interface RadarFiltersProps {
|
||||
function RadarFilters({
|
||||
availableDates,
|
||||
onChange,
|
||||
search,
|
||||
}: {
|
||||
availableDates: string[]
|
||||
onChange: (next: Partial<SectorRadarRouteSearch>) => void
|
||||
search: SectorRadarRouteSearch
|
||||
}
|
||||
|
||||
function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
||||
}) {
|
||||
const dateOptions = [
|
||||
{ label: "最近有效交易日", value: "latest" },
|
||||
...availableDates.map((value) => ({ label: value, value })),
|
||||
]
|
||||
|
||||
return (
|
||||
<div aria-label="板块资金雷达筛选" className="grid gap-2 lg:grid-cols-12">
|
||||
<FilterSelect
|
||||
@@ -183,26 +187,24 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FilterSelect
|
||||
<TabList
|
||||
ariaLabel="板块类型"
|
||||
className="lg:col-span-2"
|
||||
label="板块类型"
|
||||
options={sectorTypeOptions}
|
||||
value={search.sectorType}
|
||||
onValueChange={(value) =>
|
||||
onChange={(value) =>
|
||||
onChange({
|
||||
page: 1,
|
||||
sectorType: value as SectorRadarRouteSearch["sectorType"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FilterSelect
|
||||
className="lg:col-span-2"
|
||||
label="指标视角"
|
||||
<TabList
|
||||
ariaLabel="指标视角"
|
||||
className="lg:col-span-4"
|
||||
options={viewOptions}
|
||||
value={search.view}
|
||||
onValueChange={(value) =>
|
||||
onChange({ page: 1, view: value as SectorRadarRouteSearch["view"] })
|
||||
}
|
||||
onChange={(value) => onChange({ page: 1, view: value as RadarView })}
|
||||
/>
|
||||
{search.view === "rank_change" ? (
|
||||
<>
|
||||
@@ -212,11 +214,7 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
||||
options={metricOptions}
|
||||
value={search.rankChangeMetric}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
page: 1,
|
||||
rankChangeMetric:
|
||||
value as SectorRadarRouteSearch["rankChangeMetric"],
|
||||
})
|
||||
onChange({ page: 1, rankChangeMetric: value as RadarMetricKind })
|
||||
}
|
||||
/>
|
||||
<FilterSelect
|
||||
@@ -230,15 +228,6 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<FilterSelect
|
||||
className="lg:col-span-2"
|
||||
label="榜单范围"
|
||||
options={sideOptions}
|
||||
value={search.side}
|
||||
onValueChange={(value) =>
|
||||
onChange({ page: 1, side: value as SectorRadarRouteSearch["side"] })
|
||||
}
|
||||
/>
|
||||
<label className="grid gap-1 text-xs font-medium text-muted-foreground lg:col-span-2">
|
||||
搜索板块
|
||||
<Input
|
||||
@@ -257,6 +246,46 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function TabList({
|
||||
ariaLabel,
|
||||
className,
|
||||
onChange,
|
||||
options,
|
||||
value,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
className?: string
|
||||
onChange: (value: string) => void
|
||||
options: readonly { label: string; value: string }[]
|
||||
value: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`grid gap-1 text-xs font-medium text-muted-foreground ${className ?? ""}`}
|
||||
>
|
||||
<span>{ariaLabel}</span>
|
||||
<div
|
||||
aria-label={ariaLabel}
|
||||
className="flex rounded-md border border-input bg-background p-0.5"
|
||||
role="tablist"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
aria-selected={value === option.value}
|
||||
className={`min-h-9 flex-1 rounded-sm px-2 text-xs transition-colors ${value === option.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"}`}
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
className,
|
||||
label,
|
||||
@@ -312,9 +341,7 @@ function RadarPublicationStatus({
|
||||
currentAttempt &&
|
||||
currentAttempt.status !== "success" &&
|
||||
currentAttempt.publication_id !== lastGood?.publication_id
|
||||
|
||||
if (!degradedAttempt) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
@@ -327,106 +354,145 @@ function RadarPublicationStatus({
|
||||
)
|
||||
}
|
||||
|
||||
type RankingQueryResult = ReturnType<typeof useSectorRadarRankings>
|
||||
function RadarTable({
|
||||
hasNextPage,
|
||||
isFetchNextPageError,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
top,
|
||||
bottom,
|
||||
response,
|
||||
rows,
|
||||
}: {
|
||||
hasNextPage: boolean
|
||||
isFetchNextPageError: boolean
|
||||
isFetchingNextPage: boolean
|
||||
onLoadMore: () => Promise<unknown>
|
||||
top: RankingQueryResult
|
||||
bottom: RankingQueryResult
|
||||
response: RadarRankingsResponse
|
||||
rows: RadarRankingRow[]
|
||||
}) {
|
||||
const topRows = top.data?.pages.flatMap((page) => page.rows) ?? []
|
||||
const bottomRows = bottom.data?.pages.flatMap((page) => page.rows) ?? []
|
||||
const rowCount = Math.max(topRows.length, bottomRows.length)
|
||||
const rankChangeView = response.view === "rank_change"
|
||||
const loadMoreRequestPending = useRef(false)
|
||||
|
||||
function requestLoadMore() {
|
||||
if (loadMoreRequestPending.current || isFetchingNextPage) return
|
||||
|
||||
loadMoreRequestPending.current = true
|
||||
const releaseRequest = () => {
|
||||
loadMoreRequestPending.current = false
|
||||
}
|
||||
void onLoadMore().then(releaseRequest, releaseRequest)
|
||||
const loadMoreRequestPending = useRef({ top: false, bottom: false })
|
||||
function requestLoadMore(query: RankingQueryResult) {
|
||||
const side = query === top ? "top" : "bottom"
|
||||
if (
|
||||
loadMoreRequestPending.current[side] ||
|
||||
query.isFetchingNextPage ||
|
||||
!query.hasNextPage ||
|
||||
query.isFetchNextPageError
|
||||
)
|
||||
return
|
||||
loadMoreRequestPending.current[side] = true
|
||||
void query.fetchNextPage().finally(() => {
|
||||
loadMoreRequestPending.current[side] = false
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card">
|
||||
<div
|
||||
aria-label="板块资金排名滚动区域"
|
||||
className="min-h-0 min-w-0 flex-1 overflow-auto"
|
||||
onScroll={(event) => {
|
||||
const container = event.currentTarget
|
||||
const remaining =
|
||||
container.scrollHeight -
|
||||
container.scrollTop -
|
||||
container.clientHeight
|
||||
if (
|
||||
remaining <= 160 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage &&
|
||||
!isFetchNextPageError
|
||||
) {
|
||||
requestLoadMore()
|
||||
const c = event.currentTarget
|
||||
if (c.scrollHeight - c.scrollTop - c.clientHeight <= 160) {
|
||||
requestLoadMore(top)
|
||||
requestLoadMore(bottom)
|
||||
}
|
||||
}}
|
||||
role="region"
|
||||
tabIndex={0}
|
||||
>
|
||||
<table className="w-full min-w-[920px] border-collapse text-sm">
|
||||
<table className="w-full min-w-[1280px] border-collapse text-sm">
|
||||
<caption className="sr-only">
|
||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
|
||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
|
||||
</caption>
|
||||
<thead className="sticky top-0 z-10 bg-muted/95 text-left text-xs text-muted-foreground backdrop-blur">
|
||||
<colgroup>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<col
|
||||
key={i}
|
||||
className={i === 5 || i === 6 ? "w-[120px]" : "w-[100px]"}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-10 bg-muted/95 text-xs text-muted-foreground backdrop-blur">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
名次
|
||||
<th
|
||||
className="px-3 py-2 text-left font-medium"
|
||||
colSpan={5}
|
||||
scope="colgroup"
|
||||
>
|
||||
资金进攻榜 TOP 10%
|
||||
</th>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
板块
|
||||
<th
|
||||
className="border-x border-border px-3 py-2 text-center font-medium"
|
||||
colSpan={2}
|
||||
scope="colgroup"
|
||||
>
|
||||
{rankChangeView ? "排名变化" : response.definition.label}
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
{rankChangeView
|
||||
? `${response.rank_change_days} 日排名变化`
|
||||
: response.definition.label}
|
||||
<th
|
||||
className="px-3 py-2 text-right font-medium"
|
||||
colSpan={5}
|
||||
scope="colgroup"
|
||||
>
|
||||
BOTTOM 10% 资金撤离榜
|
||||
</th>
|
||||
{rankChangeView ? (
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
参考指标值
|
||||
</th>
|
||||
) : null}
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium" scope="col">
|
||||
排名 / 板块
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
排名百分位
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
样本
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium" scope="col">
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
资金覆盖率
|
||||
</th>
|
||||
<th className="px-3 py-2.5 font-medium" scope="col">
|
||||
<th className="px-3 py-2 text-left font-medium" scope="col">
|
||||
质量
|
||||
</th>
|
||||
<th
|
||||
className="border-l border-border px-3 py-2 text-right font-medium"
|
||||
scope="col"
|
||||
>
|
||||
{rankChangeView ? "排名上升" : "流入"}
|
||||
</th>
|
||||
<th
|
||||
className="border-r border-border px-3 py-2 text-left font-medium"
|
||||
scope="col"
|
||||
>
|
||||
{rankChangeView ? "排名下降" : "流出"}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
质量
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
资金覆盖率
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
样本
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
排名百分位
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium" scope="col">
|
||||
排名 / 板块
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{rows.map((row) => (
|
||||
<RadarTableRow
|
||||
key={`${row.sector_type}-${row.sector_code}`}
|
||||
{Array.from({ length: rowCount }, (_, i) => (
|
||||
<RadarMirrorRow
|
||||
key={i}
|
||||
rankChangeView={rankChangeView}
|
||||
row={row}
|
||||
top={topRows[i]}
|
||||
bottom={bottomRows[i]}
|
||||
/>
|
||||
))}
|
||||
{rows.length === 0 ? (
|
||||
{rowCount === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-3 py-12 text-center text-muted-foreground"
|
||||
colSpan={rankChangeView ? 8 : 7}
|
||||
colSpan={12}
|
||||
>
|
||||
没有符合当前筛选条件的板块。
|
||||
</td>
|
||||
@@ -434,7 +500,7 @@ function RadarTable({
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
{isFetchingNextPage ? (
|
||||
{top.isFetchingNextPage || bottom.isFetchingNextPage ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
|
||||
@@ -443,15 +509,43 @@ function RadarTable({
|
||||
正在加载更多板块…
|
||||
</div>
|
||||
) : null}
|
||||
{isFetchNextPageError ? (
|
||||
{top.isFetchNextPageError || bottom.isFetchNextPageError ? (
|
||||
<div
|
||||
className="flex items-center justify-center gap-2 border-t border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
|
||||
className="flex flex-wrap items-center justify-center gap-2 border-t border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
|
||||
role="alert"
|
||||
>
|
||||
<span>加载更多板块失败,已加载结果仍保留。</span>
|
||||
<Button onClick={requestLoadMore} size="sm" variant="outline">
|
||||
重试加载更多
|
||||
</Button>
|
||||
<span>
|
||||
{top.isFetchNextPageError ? "强榜加载更多失败" : null}
|
||||
{top.isFetchNextPageError && bottom.isFetchNextPageError
|
||||
? ","
|
||||
: null}
|
||||
{bottom.isFetchNextPageError ? "弱榜加载更多失败" : null}
|
||||
,已加载结果仍保留。
|
||||
</span>
|
||||
{top.isFetchNextPageError ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
loadMoreRequestPending.current.top = false
|
||||
void top.fetchNextPage()
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
重试强榜
|
||||
</Button>
|
||||
) : null}
|
||||
{bottom.isFetchNextPageError ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
loadMoreRequestPending.current.bottom = false
|
||||
void bottom.fetchNextPage()
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
重试弱榜
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -459,34 +553,57 @@ function RadarTable({
|
||||
)
|
||||
}
|
||||
|
||||
function RadarTableRow({
|
||||
function RadarMirrorRow({
|
||||
top,
|
||||
bottom,
|
||||
rankChangeView,
|
||||
row,
|
||||
}: {
|
||||
top?: RadarRankingRow
|
||||
bottom?: RadarRankingRow
|
||||
rankChangeView: boolean
|
||||
row: RadarRankingRow
|
||||
}) {
|
||||
return (
|
||||
<tr className="hover:bg-muted/40">
|
||||
<td className="px-3 py-2.5 font-semibold tabular-nums">
|
||||
<RankCells row={top} align="left" />
|
||||
<MetricCell direction="in" rankChangeView={rankChangeView} row={top} />
|
||||
<MetricCell
|
||||
direction="out"
|
||||
rankChangeView={rankChangeView}
|
||||
row={bottom}
|
||||
/>
|
||||
<RankCells row={bottom} align="right" />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
function RankCells({
|
||||
row,
|
||||
align,
|
||||
}: {
|
||||
row?: RadarRankingRow
|
||||
align: "left" | "right"
|
||||
}) {
|
||||
if (!row)
|
||||
return (
|
||||
<>
|
||||
<td className="px-3 py-2.5 text-muted-foreground">—</td>
|
||||
<td />
|
||||
<td />
|
||||
<td />
|
||||
<td />
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<td
|
||||
className={`px-3 py-2.5 ${align === "right" ? "text-right" : "text-left"} font-semibold tabular-nums`}
|
||||
>
|
||||
{row.rank_position ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-medium text-foreground">{row.sector_name}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<span className="mx-1 font-normal text-muted-foreground">/</span>
|
||||
<span className="font-medium text-foreground">{row.sector_name}</span>
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">
|
||||
{row.sector_code}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right font-medium tabular-nums">
|
||||
{rankChangeView
|
||||
? formatRankChange(row.rank_change)
|
||||
: formatMetricValue(row.metric_value, row.unit)}
|
||||
</td>
|
||||
{rankChangeView ? (
|
||||
<td className="px-3 py-2.5 text-right tabular-nums text-muted-foreground">
|
||||
{formatMetricValue(row.metric_value, row.unit)}
|
||||
</td>
|
||||
) : null}
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">
|
||||
{row.rank_percentile === null
|
||||
? "—"
|
||||
@@ -501,13 +618,64 @@ function RadarTableRow({
|
||||
<td className="px-3 py-2.5">
|
||||
<QualityBadge quality={row.quality} />
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)
|
||||
}
|
||||
function MetricCell({
|
||||
row,
|
||||
rankChangeView,
|
||||
direction,
|
||||
}: {
|
||||
row?: RadarRankingRow
|
||||
rankChangeView: boolean
|
||||
direction: "in" | "out"
|
||||
}) {
|
||||
const value = row
|
||||
? rankChangeView
|
||||
? formatRankChange(row.rank_change)
|
||||
: formatDirectionalMetric(row.metric_value, row.unit, direction)
|
||||
: "—"
|
||||
const positive =
|
||||
row?.rank_change !== null &&
|
||||
row?.rank_change !== undefined &&
|
||||
row.rank_change > 0
|
||||
const negative =
|
||||
row?.rank_change !== null &&
|
||||
row?.rank_change !== undefined &&
|
||||
row.rank_change < 0
|
||||
const borderClass = direction === "in" ? "border-l" : "border-r"
|
||||
const toneClass =
|
||||
positive || (direction === "in" && !rankChangeView)
|
||||
? "text-red-600 dark:text-red-400 bg-red-500/10"
|
||||
: negative || (direction === "out" && !rankChangeView)
|
||||
? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
|
||||
: "text-foreground bg-muted/30"
|
||||
return (
|
||||
<td
|
||||
className={`${borderClass} border-border px-3 py-2.5 ${direction === "in" ? "text-right" : "text-left"} font-medium tabular-nums ${toneClass}`}
|
||||
>
|
||||
{value}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDirectionalMetric(
|
||||
value: number | null,
|
||||
unit: RadarMetricUnit,
|
||||
direction: "in" | "out",
|
||||
) {
|
||||
if (value === null) return "—"
|
||||
const absoluteValue = Math.abs(value)
|
||||
const formatted =
|
||||
unit === "CNY_100M"
|
||||
? `${formatNumber(absoluteValue, 2)} 亿元`
|
||||
: `${formatNumber(absoluteValue * 100, 2)}%`
|
||||
return `${direction === "in" ? "+" : "−"}${formatted}`
|
||||
}
|
||||
|
||||
function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
|
||||
if (quality === "available") return <Badge variant="outline">可用</Badge>
|
||||
if (quality === "available_limited_sample") {
|
||||
if (quality === "available_limited_sample")
|
||||
return (
|
||||
<Badge
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-200"
|
||||
@@ -516,10 +684,8 @@ function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
|
||||
样本有限
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return <Badge variant="destructive">不可用</Badge>
|
||||
}
|
||||
|
||||
function RadarLoading() {
|
||||
return (
|
||||
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
||||
@@ -537,7 +703,6 @@ function RadarLoading() {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarRefreshStatus() {
|
||||
return (
|
||||
<div
|
||||
@@ -548,7 +713,6 @@ function RadarRefreshStatus() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarRefreshFailureStatus() {
|
||||
return (
|
||||
<div
|
||||
@@ -559,7 +723,6 @@ function RadarRefreshFailureStatus() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarError({ onRetry }: { onRetry: () => void }) {
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
@@ -584,7 +747,6 @@ function RadarError({ onRetry }: { onRetry: () => void }) {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarNoData() {
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
@@ -600,28 +762,18 @@ function RadarNoData() {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function formatMetricValue(value: number | null, unit: RadarMetricUnit) {
|
||||
if (value === null) return "—"
|
||||
return unit === "CNY_100M"
|
||||
? `${formatNumber(value, 2)} 亿元`
|
||||
: `${formatNumber(value * 100, 2)}%`
|
||||
}
|
||||
|
||||
function formatRankChange(value: number | null) {
|
||||
if (value === null) return "暂无可比历史"
|
||||
if (value > 0) return `+${value}`
|
||||
return String(value)
|
||||
if (value > 0) return `+${value} 名`
|
||||
if (value < 0) return `${value} 名`
|
||||
return "0 名"
|
||||
}
|
||||
|
||||
function formatCoverage(value: number) {
|
||||
return `${formatNumber(value * 100, 1)}%`
|
||||
}
|
||||
|
||||
function formatNumber(value: number, maximumFractionDigits: number) {
|
||||
return value.toLocaleString("zh-CN", { maximumFractionDigits })
|
||||
}
|
||||
|
||||
function attemptStatusLabel(status: RadarPublication["status"]) {
|
||||
if (status === "running") return "新一期仍在构建"
|
||||
if (status === "partial") return "新一期数据不完整"
|
||||
|
||||
Reference in New Issue
Block a user