Compare commits

...

3 Commits

Author SHA1 Message Date
yuxuanhui fe6d2b4fe5 feat(sector-radar): align dual ranking radar interactions 2026-09-01 13:24:43 +08:00
yuxuanhui 21ec0b2353 chore: record journal 2026-09-01 12:14:37 +08:00
yuxuanhui e2037f48f0 chore(task): archive 09-01-sector-radar-dual-ranking 2026-09-01 12:14:25 +08:00
10 changed files with 631 additions and 201 deletions
@@ -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": {}
}
+3 -2
View File
@@ -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` |
+21
View File
@@ -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 "新一期数据不完整"