Merge pull request 'Develop' (#21) from develop into main
Deploy Production / deploy (push) Successful in 55s

Reviewed-on: sakibcc/zhixing-system#21
This commit was merged in pull request #21.
This commit is contained in:
2026-09-01 11:30:21 +08:00
19 changed files with 475 additions and 199 deletions
@@ -5,3 +5,7 @@
{"file":".trellis/tasks/08-27-onechartlab-research/prd.md","reason":"逐条核对两份独立报告、复现深度、证据定位和范围边界的验收条件。"} {"file":".trellis/tasks/08-27-onechartlab-research/prd.md","reason":"逐条核对两份独立报告、复现深度、证据定位和范围边界的验收条件。"}
{"file":".trellis/tasks/08-27-onechartlab-research/research/sector-capital-radar-evidence.md","reason":"核对板块报告的已确认事实、推断边界和排名复算结果。"} {"file":".trellis/tasks/08-27-onechartlab-research/research/sector-capital-radar-evidence.md","reason":"核对板块报告的已确认事实、推断边界和排名复算结果。"}
{"file":".trellis/tasks/08-27-onechartlab-research/research/macro-timing-evidence.md","reason":"核对宏观报告的阈值、状态、贡献恒等式和核心公式缺口。"} {"file":".trellis/tasks/08-27-onechartlab-research/research/macro-timing-evidence.md","reason":"核对宏观报告的阈值、状态、贡献恒等式和核心公式缺口。"}
{"file": ".trellis/spec/frontend/index.md", "reason": "核对本轮前端实现是否符合项目分层和门禁。"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "核对无限查询、query key、取消信号和服务器状态归属。"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "核对滚动表格、状态提示、语义和可访问性。"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "核对测试覆盖以及 format、lint、typecheck、test、build 结果。"}
@@ -32,3 +32,11 @@
## 验证与回滚 ## 验证与回滚
最终检查逐条核对 PRD 验收标准、报告内引用的可访问性、事实/推断分级、两模块的一致术语和复现说明完整性。报告是新增 Markdown 文件,回滚只需移除新增报告和任务底稿,不影响运行时系统。 最终检查逐条核对 PRD 验收标准、报告内引用的可访问性、事实/推断分级、两模块的一致术语和复现说明完整性。报告是新增 Markdown 文件,回滚只需移除新增报告和任务底稿,不影响运行时系统。
## 板块排名页交互迭代
排名接口继续沿用现有 `page` / `page_size` 服务端契约,前端 query 层新增无限查询封装,以首屏页为起点,根据已加载条数和响应 `total` 推导下一页。页面只消费 React Query 管理的分页集合,不把服务端响应复制到 Zustand 或组件本地状态;筛选条件参与 query key,条件变化自然切换到新的首批数据。
表格自身保持唯一纵向滚动容器和 sticky 表头,在滚动位置接近底部时调用 `fetchNextPage`。加载下一批时在表格底部提供紧凑状态,加载失败时保留已加载行并提供可重试操作,全部加载后不显示传统页码或每页条数控制。
移除 `RadarStatusSummary` 及其静态标题、免责声明、版本和发布元数据块。部分发布、后台刷新、刷新失败、首次加载失败和无数据仍属于操作性状态,继续以紧凑提示或状态容器呈现,不删除关键异常反馈。
@@ -5,3 +5,7 @@
{"file":".trellis/tasks/08-27-onechartlab-research/design.md","reason":"提供证据等级、两路页面调研分工、视频暂缓边界、复现链路和访问限制。"} {"file":".trellis/tasks/08-27-onechartlab-research/design.md","reason":"提供证据等级、两路页面调研分工、视频暂缓边界、复现链路和访问限制。"}
{"file":".trellis/tasks/08-27-onechartlab-research/research/sector-capital-radar-evidence.md","reason":"提供板块页面、公开分片、排名算法、失败边界和主会话复算证据。"} {"file":".trellis/tasks/08-27-onechartlab-research/research/sector-capital-radar-evidence.md","reason":"提供板块页面、公开分片、排名算法、失败边界和主会话复算证据。"}
{"file":".trellis/tasks/08-27-onechartlab-research/research/macro-timing-evidence.md","reason":"提供宏观页面、状态/贡献协议、阈值、证据缺口和主会话复算证据。"} {"file":".trellis/tasks/08-27-onechartlab-research/research/macro-timing-evidence.md","reason":"提供宏观页面、状态/贡献协议、阈值、证据缺口和主会话复算证据。"}
{"file": ".trellis/spec/frontend/index.md", "reason": "板块资金雷达页面是 React/TanStack Query 前端改动,需遵守前端目录、交互和质量门禁。"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "无限列表继续由 TanStack Query 管理服务端分页和取消信号。"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "表格滚动、状态提示和控件需遵守现有组件、Tailwind 与可访问性约定。"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "实现后需更新行为测试并通过前端质量命令。"}
@@ -10,6 +10,10 @@
- [x] 编写 `docs/research/onechartlab-sector-capital-radar.md`,覆盖输入、加工链路、排序/打分/映射、输出信号、更新和异常边界、复现伪代码及证据置信度。 - [x] 编写 `docs/research/onechartlab-sector-capital-radar.md`,覆盖输入、加工链路、排序/打分/映射、输出信号、更新和异常边界、复现伪代码及证据置信度。
- [x] 编写 `docs/research/onechartlab-macro-timing.md`,覆盖输入、状态定义、阈值/状态机、滞后与前视风险、资产解释、复现伪代码及证据置信度。 - [x] 编写 `docs/research/onechartlab-macro-timing.md`,覆盖输入、状态定义、阈值/状态机、滞后与前视风险、资产解释、复现伪代码及证据置信度。
- [x] 执行最终内容检查,确认每条关键技术结论有出处或推断标签,两份报告可以独立阅读且不包含收益承诺或未经确认的精确参数。 - [x] 执行最终内容检查,确认每条关键技术结论有出处或推断标签,两份报告可以独立阅读且不包含收益承诺或未经确认的精确参数。
- [x] 在 sector radar query 层加入基于现有分页接口的无限查询,并用单元测试覆盖 query key、取消信号和下一页判定。
- [x] 将板块排名表改为滚动接近底部时加载下一页,移除共享分页器接入,确保筛选变化时列表重置且下一批失败不会清空已加载行。
- [x] 删除 `RadarStatusSummary` 大块描述内容,把部分发布等必要状态保留为紧凑提示,并更新页面行为测试。
- [x] 运行前端 format、lint、typecheck、相关 Vitest、完整测试与 build,随后进行可见页面检查。
## 验证命令与人工检查 ## 验证命令与人工检查
@@ -23,6 +27,15 @@ git diff --check
人工核对报告中的关键 URL 和公开请求字段,检查证据等级是否与正文措辞一致,并确认视频暂缓状态及其影响已写清楚。 人工核对报告中的关键 URL 和公开请求字段,检查证据等级是否与正文措辞一致,并确认视频暂缓状态及其影响已写清楚。
页面迭代额外执行:
```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
```
## 风险与停止条件 ## 风险与停止条件
如果网站需要绕过登录、付费、验证码、地域限制或反爬机制才能继续,停止该取证路径并记录缺口。若压缩/混淆脚本只能支持候选实现而不能证明算法,降级为推断。若公开数据只给出最终分值,报告提供可实施的验证实验,而不虚构原始公式。 如果网站需要绕过登录、付费、验证码、地域限制或反爬机制才能继续,停止该取证路径并记录缺口。若压缩/混淆脚本只能支持候选实现而不能证明算法,降级为推断。若公开数据只给出最终分值,报告提供可实施的验证实验,而不虚构原始公式。
@@ -35,10 +35,14 @@
- [x] 两份报告不存在投资收益承诺,并明确其用途是技术与产品逻辑研究,不构成投资建议。 - [x] 两份报告不存在投资收益承诺,并明确其用途是技术与产品逻辑研究,不构成投资建议。
- [x] 生成一份 Tushare 数据需求研究文档,能够作为后续采集、落库和独立重算的接口清单与实施边界。 - [x] 生成一份 Tushare 数据需求研究文档,能够作为后续采集、落库和独立重算的接口清单与实施边界。
- [x] Tushare 文档明确区分页面已暴露的确定接口、为复现建议补充的接口和仍需实测/开通权限确认的接口;关键字段及权限信息引用官方来源。 - [x] Tushare 文档明确区分页面已暴露的确定接口、为复现建议补充的接口和仍需实测/开通权限确认的接口;关键字段及权限信息引用官方来源。
- [x] 板块资金雷达排名页取消底部分页器,首批数据展示后可在表格滚动区接近底部时继续加载下一批,直到当前筛选条件下全部结果加载完成。
- [x] 切换交易日、板块类型、指标视角、榜单范围或搜索条件后,无限列表从首批结果重新开始,不混入旧筛选条件的数据。
- [x] 移除排名表上方包含标题、免责声明、实现标签、版本号和发布元数据的整块描述性卡片,不再用大面积静态说明挤占榜单空间。
- [x] 刷新失败、后台刷新、部分发布、加载失败和无数据等操作性状态仍能以紧凑且可访问的方式向用户表达。
## Out of Scope ## Out of Scope
- 绕过登录、会员、验证码、地域限制、反爬机制或其他访问控制。 - 绕过登录、会员、验证码、地域限制、反爬机制或其他访问控制。
- 对未公开的后端源码、私有数据源或专有算法作确定性归因。 - 对未公开的后端源码、私有数据源或专有算法作确定性归因。
- 开发、回测或上线等价策略,评估真实交易收益,或提供个性化投资建议。 - 开发、回测或上线等价策略,评估真实交易收益,或提供个性化投资建议;本轮仅迭代既有板块资金雷达页面的列表交互与信息密度。
- 修改 OneChartLab 网站或向其作者、用户发送消息。 - 修改 OneChartLab 网站或向其作者、用户发送消息。
@@ -3,7 +3,7 @@
"name": "onechartlab-research", "name": "onechartlab-research",
"title": "OneChartLab 板块资金雷达与宏观择时技术调研", "title": "OneChartLab 板块资金雷达与宏观择时技术调研",
"description": "", "description": "",
"status": "in_progress", "status": "completed",
"dev_type": null, "dev_type": null,
"scope": null, "scope": null,
"package": null, "package": null,
@@ -11,7 +11,7 @@
"creator": "yuxuanhui", "creator": "yuxuanhui",
"assignee": "yuxuanhui", "assignee": "yuxuanhui",
"createdAt": "2026-08-27", "createdAt": "2026-08-27",
"completedAt": null, "completedAt": "2026-09-01",
"branch": null, "branch": null,
"base_branch": "main", "base_branch": "main",
"worktree_path": null, "worktree_path": null,
+4 -3
View File
@@ -8,8 +8,8 @@
<!-- @@@auto:current-status --> <!-- @@@auto:current-status -->
- **Active File**: `journal-1.md` - **Active File**: `journal-1.md`
- **Total Sessions**: 12 - **Total Sessions**: 13
- **Last Active**: 2026-08-31 - **Last Active**: 2026-09-01
<!-- @@@/auto:current-status --> <!-- @@@/auto:current-status -->
--- ---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents --> <!-- @@@auto:active-documents -->
| File | Lines | Status | | File | Lines | Status |
|------|-------|--------| |------|-------|--------|
| `journal-1.md` | ~313 | Active | | `journal-1.md` | ~335 | Active |
<!-- @@@/auto:active-documents --> <!-- @@@/auto:active-documents -->
--- ---
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history --> <!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch | | # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------| |---|------|-------|---------|--------|
| 13 | 2026-09-01 | 板块资金雷达滚动加载迭代 | `be31235`, `5f34be5` | `develop` |
| 12 | 2026-08-31 | 集成知行 B1 FastDTW 图形评分 | `6ce291e`, `5800661` | `codex/point` | | 12 | 2026-08-31 | 集成知行 B1 FastDTW 图形评分 | `6ce291e`, `5800661` | `codex/point` |
| 11 | 2026-08-31 | 资金雷达当前上市股票资金流补拉 | `2ffd016` | `codex/sector-radar-listed-moneyflow-recovery` | | 11 | 2026-08-31 | 资金雷达当前上市股票资金流补拉 | `2ffd016` | `codex/sector-radar-listed-moneyflow-recovery` |
| 10 | 2026-08-29 | 完成板块资金雷达 Tushare 独立生产 MVP | `3789008`, `284c480`, `d9bae72`, `efc4c3d`, `8e96e64`, `23493fa`, `2fd16e5` | `codex/zijin` | | 10 | 2026-08-29 | 完成板块资金雷达 Tushare 独立生产 MVP | `3789008`, `284c480`, `d9bae72`, `efc4c3d`, `8e96e64`, `23493fa`, `2fd16e5` | `codex/zijin` |
+22
View File
@@ -311,3 +311,25 @@
### Status ### Status
[OK] **Completed** [OK] **Completed**
## Session 13: 板块资金雷达滚动加载迭代
**Date**: 2026-09-01
**Task**: 板块资金雷达滚动加载迭代
**Branch**: `develop`
### Summary
将板块资金排名改为基于现有分页 API 的无限滚动列表,移除大块描述卡与底部分页器,保留异常和加载更多重试状态;补齐边界测试并通过完整前端检查与浏览器实测。
### Git Commits
| Hash | Message |
|------|---------|
| `be31235` | (see git log) |
| `5f34be5` | (see git log) |
### Status
[OK] **Completed**
@@ -1,12 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest" import { beforeEach, describe, expect, it, vi } from "vitest"
const useQuery = vi.hoisted(() => vi.fn()) const useQuery = vi.hoisted(() => vi.fn())
const useInfiniteQuery = vi.hoisted(() => vi.fn())
const api = vi.hoisted(() => ({ const api = vi.hoisted(() => ({
getSectorRadarDates: vi.fn(), getSectorRadarDates: vi.fn(),
getSectorRadarRankings: vi.fn(), getSectorRadarRankings: vi.fn(),
})) }))
vi.mock("@tanstack/react-query", () => ({ useQuery })) vi.mock("@tanstack/react-query", () => ({ useInfiniteQuery, useQuery }))
vi.mock("./sector-radar.api", () => api) vi.mock("./sector-radar.api", () => api)
import { import {
@@ -32,9 +33,10 @@ describe("sector radar query hooks", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
useQuery.mockImplementation((options) => options) useQuery.mockImplementation((options) => options)
useInfiniteQuery.mockImplementation((options) => options)
}) })
it("keeps every server-affecting filter in the ranking query key", () => { it("keeps filters in one ranking key without splitting cached pages", () => {
expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"]) expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"])
expect(sectorRadarRankingsQueryKey(query)).toEqual([ expect(sectorRadarRankingsQueryKey(query)).toEqual([
"sectorRadar", "sectorRadar",
@@ -46,9 +48,11 @@ describe("sector radar query hooks", () => {
3, 3,
"bottom", "bottom",
"银行", "银行",
2,
50, 50,
]) ])
expect(
sectorRadarRankingsQueryKey({ ...query, page: query.page + 1 }),
).toEqual(sectorRadarRankingsQueryKey(query))
}) })
it("forwards React Query cancellation signals to both adapters", async () => { it("forwards React Query cancellation signals to both adapters", async () => {
@@ -57,15 +61,98 @@ describe("sector radar query hooks", () => {
const datesOptions = useQuery.mock.calls[0]?.[0] as { const datesOptions = useQuery.mock.calls[0]?.[0] as {
queryFn: (context: { signal: AbortSignal }) => unknown queryFn: (context: { signal: AbortSignal }) => unknown
} }
const rankingsOptions = useQuery.mock.calls[1]?.[0] as { const rankingsOptions = useInfiniteQuery.mock.calls[0]?.[0] as {
queryFn: (context: { signal: AbortSignal }) => unknown queryFn: (context: { pageParam: number; signal: AbortSignal }) => unknown
} }
const signal = new AbortController().signal const signal = new AbortController().signal
await datesOptions.queryFn({ signal }) await datesOptions.queryFn({ signal })
await rankingsOptions.queryFn({ signal }) await rankingsOptions.queryFn({ pageParam: 3, signal })
expect(api.getSectorRadarDates).toHaveBeenCalledWith(signal) expect(api.getSectorRadarDates).toHaveBeenCalledWith(signal)
expect(api.getSectorRadarRankings).toHaveBeenCalledWith(query, signal) expect(api.getSectorRadarRankings).toHaveBeenCalledWith(
{ ...query, page: 3 },
signal,
)
})
it("starts from page one and stops after loaded rows cover total", () => {
useSectorRadarRankings(query)
const rankingsOptions = useInfiniteQuery.mock.calls[0]?.[0] as {
getNextPageParam: (
lastPage: { page: number; rows: unknown[]; total: number },
pages: { page: number; rows: unknown[]; total: number }[],
lastPageParam: number,
) => number | undefined
initialPageParam: number
}
const firstPage = {
page: 1,
rows: Array(50).fill(null),
status: "success",
total: 75,
}
const secondPage = {
page: 2,
rows: Array(25).fill(null),
status: "success",
total: 75,
}
expect(rankingsOptions.initialPageParam).toBe(1)
expect(rankingsOptions.getNextPageParam(firstPage, [firstPage], 1)).toBe(2)
expect(
rankingsOptions.getNextPageParam(secondPage, [firstPage, secondPage], 2),
).toBeUndefined()
})
it("stops when a page is empty or does not match its requested page param", () => {
useSectorRadarRankings(query)
const rankingsOptions = useInfiniteQuery.mock.calls[0]?.[0] as {
getNextPageParam: (
lastPage: {
page: number
rows: unknown[]
status: "success" | "no_data"
total: number
},
pages: {
page: number
rows: unknown[]
status: "success" | "no_data"
total: number
}[],
lastPageParam: number,
) => number | undefined
}
const firstPage = {
page: 1,
rows: Array(50).fill(null),
status: "success" as const,
total: 75,
}
const emptyPage = {
page: 2,
rows: [],
status: "success" as const,
total: 75,
}
const mismatchedPage = {
page: 1,
rows: Array(25).fill(null),
status: "success" as const,
total: 75,
}
expect(
rankingsOptions.getNextPageParam(emptyPage, [firstPage, emptyPage], 2),
).toBeUndefined()
expect(
rankingsOptions.getNextPageParam(
mismatchedPage,
[firstPage, mismatchedPage],
2,
),
).toBeUndefined()
}) })
}) })
@@ -1,7 +1,14 @@
import { useQuery } from "@tanstack/react-query" import {
useInfiniteQuery,
useQuery,
type InfiniteData,
} from "@tanstack/react-query"
import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api" import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api"
import type { RadarRankingsQuery } from "./sector-radar.types" import type {
RadarRankingsQuery,
RadarRankingsResponse,
} from "./sector-radar.types"
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
@@ -16,7 +23,6 @@ export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) =>
query.rankChangeDays, query.rankChangeDays,
query.side, query.side,
query.search ?? "", query.search ?? "",
query.page,
query.pageSize, query.pageSize,
] as const ] as const
@@ -28,8 +34,33 @@ export function useSectorRadarDates() {
} }
export function useSectorRadarRankings(query: RadarRankingsQuery) { export function useSectorRadarRankings(query: RadarRankingsQuery) {
return useQuery({ return useInfiniteQuery<
queryFn: ({ signal }) => getSectorRadarRankings(query, signal), RadarRankingsResponse,
Error,
InfiniteData<RadarRankingsResponse>,
ReturnType<typeof sectorRadarRankingsQueryKey>,
number
>({
getNextPageParam: (lastPage, pages, lastPageParam) => {
const loadedRowCount = pages.reduce(
(count, page) => count + page.rows.length,
0,
)
if (
lastPage.status !== "success" ||
lastPage.rows.length === 0 ||
lastPage.page !== lastPageParam ||
loadedRowCount >= lastPage.total
) {
return undefined
}
const nextPageParam = lastPageParam + 1
return Number.isSafeInteger(nextPageParam) ? nextPageParam : undefined
},
initialPageParam: 1,
queryFn: ({ pageParam, signal }) =>
getSectorRadarRankings({ ...query, page: pageParam }, signal),
queryKey: sectorRadarRankingsQueryKey(query), queryKey: sectorRadarRankingsQueryKey(query),
}) })
} }
@@ -14,6 +14,7 @@ const useSectorRadarDates = vi.fn()
const useSectorRadarRankings = vi.fn() const useSectorRadarRankings = vi.fn()
const refetchDates = vi.fn() const refetchDates = vi.fn()
const refetchRankings = vi.fn() const refetchRankings = vi.fn()
const fetchNextPage = vi.fn()
let routeSearch: SectorRadarRouteSearch let routeSearch: SectorRadarRouteSearch
@@ -114,6 +115,21 @@ const rankingsResponse: RadarRankingsResponse = {
], ],
} }
function rankingQueryResult(overrides: Record<string, unknown> = {}) {
return {
data: { pageParams: [1], pages: [rankingsResponse] },
fetchNextPage,
hasNextPage: false,
isError: false,
isFetchNextPageError: false,
isFetching: false,
isFetchingNextPage: false,
isPending: false,
refetch: refetchRankings,
...overrides,
}
}
describe("SectorRadarPage", () => { describe("SectorRadarPage", () => {
beforeEach(() => { beforeEach(() => {
routeSearch = { routeSearch = {
@@ -134,36 +150,43 @@ describe("SectorRadarPage", () => {
isPending: false, isPending: false,
refetch: refetchDates, refetch: refetchDates,
}) })
useSectorRadarRankings.mockReturnValue({ useSectorRadarRankings.mockReturnValue(rankingQueryResult())
data: rankingsResponse,
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
navigate.mockReset() navigate.mockReset()
refetchDates.mockReset() refetchDates.mockReset()
refetchRankings.mockReset() refetchRankings.mockReset()
fetchNextPage.mockReset()
fetchNextPage.mockResolvedValue(undefined)
}) })
it("renders independent metric provenance, units, quality, and ranking rows", () => { it("renders ranking rows without the description card or pagination", () => {
render(<SectorRadarPage />) render(<SectorRadarPage />)
expect(screen.getAllByText("知行独立实现").length).toBeGreaterThan(0)
expect(screen.getByText("zhixing_amount_net_bn_v1")).toBeInTheDocument()
expect(screen.getByText("12.5 亿元")).toBeInTheDocument() expect(screen.getByText("12.5 亿元")).toBeInTheDocument()
expect(screen.getByText("机器人")).toBeInTheDocument() expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.getByText("样本有限")).toBeInTheDocument() expect(screen.getByText("样本有限")).toBeInTheDocument()
expect(screen.getByText("95%")).toBeInTheDocument() expect(screen.getByText("95%")).toBeInTheDocument()
expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument()
expect(
screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"),
).not.toBeInTheDocument()
expect(
screen.queryByText("zhixing_amount_net_bn_v1"),
).not.toBeInTheDocument()
expect(
screen.queryByRole("button", { name: "第 1 页" }),
).not.toBeInTheDocument()
expect(
screen.queryByRole("combobox", { name: "每页数量" }),
).not.toBeInTheDocument()
}) })
it("stores search and pagination changes in router search state", () => { it("resets filters to the first batch in router search state", () => {
routeSearch = { ...routeSearch, page: 3 }
render(<SectorRadarPage />) render(<SectorRadarPage />)
fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), { fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), {
target: { value: "机器人" }, target: { value: "机器人" },
}) })
fireEvent.click(screen.getByRole("button", { name: "第 1 页" }))
const searchCall = navigate.mock.calls[0]?.[0].search as ( const searchCall = navigate.mock.calls[0]?.[0].search as (
previous: SectorRadarRouteSearch, previous: SectorRadarRouteSearch,
@@ -174,24 +197,112 @@ describe("SectorRadarPage", () => {
}) })
}) })
it("loads the next page once when repeated scroll events reach the bottom", () => {
fetchNextPage.mockReturnValue(new Promise(() => undefined))
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({ hasNextPage: true }),
)
render(<SectorRadarPage />)
const scrollRegion = screen.getByRole("region", {
name: "板块资金排名滚动区域",
})
Object.defineProperties(scrollRegion, {
clientHeight: { configurable: true, value: 300 },
scrollHeight: { configurable: true, value: 1_000 },
scrollTop: { configurable: true, value: 550 },
})
fireEvent.scroll(scrollRegion)
fireEvent.scroll(scrollRegion)
expect(fetchNextPage).toHaveBeenCalledOnce()
})
it("announces that the next page is loading without hiding loaded rows", () => {
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({
hasNextPage: true,
isFetching: true,
isFetchingNextPage: true,
}),
)
render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent("正在加载更多板块")
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.queryByText("雷达数据正在刷新")).not.toBeInTheDocument()
})
it("merges rows from every loaded page", () => {
const firstPage = { ...rankingsResponse, total: 3 }
const nextPage = {
...rankingsResponse,
page: 2,
total: 3,
rows: [
{
...rankingsResponse.rows[0],
sector_code: "BK0003.DC",
sector_name: "商业航天",
rank_position: 3,
},
],
}
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({
data: { pageParams: [1, 2], pages: [firstPage, nextPage] },
}),
)
render(<SectorRadarPage />)
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.getByText("低空经济")).toBeInTheDocument()
expect(screen.getByText("商业航天")).toBeInTheDocument()
})
it("keeps loaded rows and offers retry when loading more fails", () => {
useSectorRadarRankings.mockReturnValue(
rankingQueryResult({
hasNextPage: true,
isError: true,
isFetchNextPageError: true,
}),
)
render(<SectorRadarPage />)
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.getByRole("alert")).toHaveTextContent(
"加载更多板块失败,已加载结果仍保留",
)
expect(screen.queryByText("雷达数据刷新失败")).not.toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "重试加载更多" }))
expect(fetchNextPage).toHaveBeenCalledOnce()
})
it("shows rank changes without inventing missing history", () => { it("shows rank changes without inventing missing history", () => {
routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 } routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 }
useSectorRadarRankings.mockReturnValue({ useSectorRadarRankings.mockReturnValue(
data: { rankingQueryResult({
...rankingsResponse, data: {
view: "rank_change", pageParams: [1],
rank_change_days: 5, pages: [
rows: rankingsResponse.rows.map((row, index) => ({ {
...row, ...rankingsResponse,
rank_change_days: 5, view: "rank_change",
rank_change: index === 0 ? 3 : null, rank_change_days: 5,
})), rows: rankingsResponse.rows.map((row, index) => ({
}, ...row,
isError: false, rank_change_days: 5,
isFetching: false, rank_change: index === 0 ? 3 : null,
isPending: false, })),
refetch: refetchRankings, },
}) ],
},
}),
)
render(<SectorRadarPage />) render(<SectorRadarPage />)
@@ -230,13 +341,9 @@ describe("SectorRadarPage", () => {
}) })
it("keeps the last response visible while React Query refreshes it", () => { it("keeps the last response visible while React Query refreshes it", () => {
useSectorRadarRankings.mockReturnValue({ useSectorRadarRankings.mockReturnValue(
data: rankingsResponse, rankingQueryResult({ isFetching: true }),
isError: false, )
isFetching: true,
isPending: false,
refetch: refetchRankings,
})
render(<SectorRadarPage />) render(<SectorRadarPage />)
@@ -247,13 +354,9 @@ describe("SectorRadarPage", () => {
}) })
it("keeps stale data visible when a background refresh fails", () => { it("keeps stale data visible when a background refresh fails", () => {
useSectorRadarRankings.mockReturnValue({ useSectorRadarRankings.mockReturnValue(
data: rankingsResponse, rankingQueryResult({ isError: true }),
isError: true, )
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
render(<SectorRadarPage />) render(<SectorRadarPage />)
@@ -272,8 +375,12 @@ describe("SectorRadarPage", () => {
}) })
useSectorRadarRankings.mockReturnValue({ useSectorRadarRankings.mockReturnValue({
data: undefined, data: undefined,
fetchNextPage,
hasNextPage: false,
isError: false, isError: false,
isFetchNextPageError: false,
isFetching: true, isFetching: true,
isFetchingNextPage: false,
isPending: true, isPending: true,
refetch: refetchRankings, refetch: refetchRankings,
}) })
@@ -289,8 +396,12 @@ describe("SectorRadarPage", () => {
}) })
useSectorRadarRankings.mockReturnValue({ useSectorRadarRankings.mockReturnValue({
data: undefined, data: undefined,
fetchNextPage,
hasNextPage: false,
isError: true, isError: true,
isFetchNextPageError: false,
isFetching: false, isFetching: false,
isFetchingNextPage: false,
isPending: false, isPending: false,
refetch: refetchRankings, refetch: refetchRankings,
}) })
@@ -311,19 +422,22 @@ describe("SectorRadarPage", () => {
isPending: false, isPending: false,
refetch: refetchDates, refetch: refetchDates,
}) })
useSectorRadarRankings.mockReturnValue({ useSectorRadarRankings.mockReturnValue(
data: { rankingQueryResult({
...rankingsResponse, data: {
status: "no_data", pageParams: [1],
publication: null, pages: [
rows: [], {
total: 0, ...rankingsResponse,
}, status: "no_data",
isError: false, publication: null,
isFetching: false, rows: [],
isPending: false, total: 0,
refetch: refetchRankings, },
}) ],
},
}),
)
rerender(<SectorRadarPage />) rerender(<SectorRadarPage />)
expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument() expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument()
}) })
@@ -1,5 +1,5 @@
import { AlertTriangle, Database, RefreshCw } from "lucide-react" import { AlertTriangle, Database, RefreshCw } from "lucide-react"
import { useEffect } from "react" import { useRef } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router" import { useNavigate, useSearch } from "@tanstack/react-router"
import { PageLayout } from "@/app/layout/page-layout" import { PageLayout } from "@/app/layout/page-layout"
@@ -13,6 +13,7 @@ import type {
RadarPublication, RadarPublication,
RadarRankingRow, RadarRankingRow,
RadarRankingsQuery, RadarRankingsQuery,
RadarRankingsResponse,
SectorRadarRouteSearch, SectorRadarRouteSearch,
} from "@/features/sector-radar/api/sector-radar.types" } from "@/features/sector-radar/api/sector-radar.types"
import { Badge } from "@/shared/ui/badge" import { Badge } from "@/shared/ui/badge"
@@ -25,7 +26,6 @@ import {
CardTitle, CardTitle,
} from "@/shared/ui/card" } from "@/shared/ui/card"
import { Input } from "@/shared/ui/input" import { Input } from "@/shared/ui/input"
import { Pagination } from "@/shared/ui/pagination"
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -36,8 +36,6 @@ import {
} from "@/shared/ui/select" } from "@/shared/ui/select"
import { Skeleton } from "@/shared/ui/skeleton" import { Skeleton } from "@/shared/ui/skeleton"
const PAGE_SIZE_OPTIONS = [10, 20, 50] as const
const sectorTypeOptions = [ const sectorTypeOptions = [
{ label: "概念板块", value: "concept" }, { label: "概念板块", value: "concept" },
{ label: "行业板块", value: "industry" }, { label: "行业板块", value: "industry" },
@@ -83,16 +81,9 @@ export function SectorRadarPage() {
} }
const dates = useSectorRadarDates() const dates = useSectorRadarDates()
const rankings = useSectorRadarRankings(query) const rankings = useSectorRadarRankings(query)
const total = rankings.data?.total ?? 0 const rankingPages = rankings.data?.pages ?? []
const pageCount = Math.max(1, Math.ceil(total / search.pageSize)) const firstRankingPage = rankingPages[0]
const rankingRows = rankingPages.flatMap((page) => page.rows)
useEffect(() => {
if (rankings.data?.status === "success" && search.page > pageCount) {
void navigate({
search: (previous) => ({ ...previous, page: pageCount }),
})
}
}, [navigate, pageCount, rankings.data?.status, search.page])
function updateSearch(next: Partial<SectorRadarRouteSearch>) { function updateSearch(next: Partial<SectorRadarRouteSearch>) {
void navigate({ search: (previous) => ({ ...previous, ...next }) }) void navigate({ search: (previous) => ({ ...previous, ...next }) })
@@ -100,13 +91,17 @@ export function SectorRadarPage() {
const loading = dates.isPending || rankings.isPending const loading = dates.isPending || rankings.isPending
const refreshing = const refreshing =
!loading && (Boolean(dates.isFetching) || Boolean(rankings.isFetching)) !loading &&
(Boolean(dates.isFetching) ||
(Boolean(rankings.isFetching) && !rankings.isFetchingNextPage))
const fatalError = const fatalError =
(dates.isError && !dates.data) || (rankings.isError && !rankings.data) (dates.isError && !dates.data) || (rankings.isError && !rankings.data)
const refreshFailed = const refreshFailed =
!fatalError && (Boolean(dates.isError) || Boolean(rankings.isError)) !fatalError &&
(Boolean(dates.isError) ||
(Boolean(rankings.isError) && !rankings.isFetchNextPageError))
const noData = const noData =
dates.data?.status === "no_data" || rankings.data?.status === "no_data" dates.data?.status === "no_data" || firstRankingPage?.status === "no_data"
return ( return (
<PageLayout <PageLayout
@@ -134,29 +129,26 @@ export function SectorRadarPage() {
!fatalError && !fatalError &&
!noData && !noData &&
dates.data && dates.data &&
rankings.data?.status === "success" && firstRankingPage?.status === "success" &&
rankings.data.publication ? ( firstRankingPage.publication ? (
<> <>
{refreshFailed ? ( {refreshFailed ? (
<RadarRefreshFailureStatus /> <RadarRefreshFailureStatus />
) : refreshing ? ( ) : refreshing ? (
<RadarRefreshStatus /> <RadarRefreshStatus />
) : null} ) : null}
<RadarStatusSummary <RadarPublicationStatus
currentAttempt={dates.data.current_attempt} currentAttempt={dates.data.current_attempt}
lastGood={dates.data.last_good} lastGood={dates.data.last_good}
publication={rankings.data.publication} publication={firstRankingPage.publication}
disclaimer={rankings.data.definition.disclaimer}
metricVersion={rankings.data.definition.metric_version}
/> />
<RadarTable <RadarTable
onPageChange={(page) => updateSearch({ page })} hasNextPage={Boolean(rankings.hasNextPage)}
onPageSizeChange={(pageSize) => isFetchNextPageError={rankings.isFetchNextPageError}
updateSearch({ page: 1, pageSize }) isFetchingNextPage={rankings.isFetchingNextPage}
} onLoadMore={() => rankings.fetchNextPage()}
page={Math.min(search.page, pageCount)} response={firstRankingPage}
pageSize={search.pageSize} rows={rankingRows}
response={rankings.data}
/> />
</> </>
) : null} ) : null}
@@ -307,17 +299,13 @@ function FilterSelect({
) )
} }
function RadarStatusSummary({ function RadarPublicationStatus({
currentAttempt, currentAttempt,
disclaimer,
lastGood, lastGood,
metricVersion,
publication, publication,
}: { }: {
currentAttempt: RadarPublication | null currentAttempt: RadarPublication | null
disclaimer: string
lastGood: RadarPublication | null lastGood: RadarPublication | null
metricVersion: string
publication: RadarPublication publication: RadarPublication
}) { }) {
const degradedAttempt = const degradedAttempt =
@@ -325,80 +313,71 @@ function RadarStatusSummary({
currentAttempt.status !== "success" && currentAttempt.status !== "success" &&
currentAttempt.publication_id !== lastGood?.publication_id currentAttempt.publication_id !== lastGood?.publication_id
return ( if (!degradedAttempt) return null
<Card className="shrink-0 shadow-none">
<CardHeader className="gap-3 p-4 pb-3">
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">收盘后板块资金排名</CardTitle>
<CardDescription className="mt-1">{disclaimer}</CardDescription>
</div>
<Badge variant="outline">知行独立实现</Badge>
<Badge variant="outline">{metricVersion}</Badge>
</div>
{degradedAttempt ? (
<div
role="status"
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
>
{attemptStatusLabel(currentAttempt.status)}:目标交易日{" "}
{currentAttempt.target_trade_date}
,当前仍展示最近有效发布{" "}
{lastGood?.target_trade_date ?? publication.target_trade_date}。
</div>
) : null}
</CardHeader>
<CardContent className="grid gap-3 p-4 pt-0 text-xs sm:grid-cols-2 lg:grid-cols-5">
<SummaryItem label="数据交易日" value={publication.target_trade_date} />
<SummaryItem
label="发布完成"
value={formatDateTime(publication.finished_at)}
/>
<SummaryItem
label="全局覆盖率"
value={formatCoverage(publication.coverage)}
/>
<SummaryItem label="事实来源" value={publication.source_version} />
<SummaryItem
label="板块范围版本"
value={publication.universe_version}
/>
</CardContent>
</Card>
)
}
function SummaryItem({ label, value }: { label: string; value: string }) {
return ( return (
<div> <div
<p className="text-muted-foreground">{label}</p> role="status"
<p className="mt-1 break-all font-medium text-foreground tabular-nums"> className="shrink-0 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
{value} >
</p> {attemptStatusLabel(currentAttempt.status)}:目标交易日{" "}
{currentAttempt.target_trade_date},当前仍展示最近有效发布{" "}
{lastGood?.target_trade_date ?? publication.target_trade_date}。
</div> </div>
) )
} }
function RadarTable({ function RadarTable({
onPageChange, hasNextPage,
onPageSizeChange, isFetchNextPageError,
page, isFetchingNextPage,
pageSize, onLoadMore,
response, response,
rows,
}: { }: {
onPageChange: (page: number) => void hasNextPage: boolean
onPageSizeChange: (pageSize: number) => void isFetchNextPageError: boolean
page: number isFetchingNextPage: boolean
pageSize: number onLoadMore: () => Promise<unknown>
response: Exclude< response: RadarRankingsResponse
ReturnType<typeof useSectorRadarRankings>["data"], rows: RadarRankingRow[]
undefined
>
}) { }) {
const rankChangeView = response.view === "rank_change" 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)
}
return ( return (
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card"> <section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card">
<div className="min-h-0 min-w-0 flex-1 overflow-auto"> <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()
}
}}
role="region"
tabIndex={0}
>
<table className="w-full min-w-[920px] border-collapse text-sm"> <table className="w-full min-w-[920px] border-collapse text-sm">
<caption className="sr-only"> <caption className="sr-only">
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名 {response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
@@ -436,14 +415,14 @@ function RadarTable({
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border/60"> <tbody className="divide-y divide-border/60">
{response.rows.map((row) => ( {rows.map((row) => (
<RadarTableRow <RadarTableRow
key={`${row.sector_type}-${row.sector_code}`} key={`${row.sector_type}-${row.sector_code}`}
rankChangeView={rankChangeView} rankChangeView={rankChangeView}
row={row} row={row}
/> />
))} ))}
{response.rows.length === 0 ? ( {rows.length === 0 ? (
<tr> <tr>
<td <td
className="px-3 py-12 text-center text-muted-foreground" className="px-3 py-12 text-center text-muted-foreground"
@@ -455,15 +434,27 @@ function RadarTable({
) : null} ) : null}
</tbody> </tbody>
</table> </table>
{isFetchingNextPage ? (
<div
aria-live="polite"
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
role="status"
>
正在加载更多板块…
</div>
) : null}
{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"
role="alert"
>
<span>加载更多板块失败,已加载结果仍保留。</span>
<Button onClick={requestLoadMore} size="sm" variant="outline">
重试加载更多
</Button>
</div>
) : null}
</div> </div>
<Pagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
page={page}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={response.total}
/>
</section> </section>
) )
} }
@@ -631,15 +622,6 @@ function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString("zh-CN", { maximumFractionDigits }) return value.toLocaleString("zh-CN", { maximumFractionDigits })
} }
function formatDateTime(value: string | null) {
if (!value) return "—"
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Shanghai",
}).format(new Date(value))
}
function attemptStatusLabel(status: RadarPublication["status"]) { function attemptStatusLabel(status: RadarPublication["status"]) {
if (status === "running") return "新一期仍在构建" if (status === "running") return "新一期仍在构建"
if (status === "partial") return "新一期数据不完整" if (status === "partial") return "新一期数据不完整"
@@ -90,6 +90,14 @@ export function getSignalJValue(details: SelectionSignal["details"]) {
return formatSignalDetailValue("j", details.j) return formatSignalDetailValue("j", details.j)
} }
export function getStockJValue(stock: SelectionStockResult) {
const signal = stock.signals.find(
({ details }) =>
details.j !== null && details.j !== undefined && details.j !== "",
)
return getSignalJValue(signal?.details ?? {})
}
function formatSignalDetailValue( function formatSignalDetailValue(
key: string, key: string,
value: number | string | null | undefined, value: number | string | null | undefined,
@@ -8,7 +8,7 @@ import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score"
import { import {
categoryToneClass, categoryToneClass,
getCategoryLabel, getCategoryLabel,
getSignalJValue, getStockJValue,
getStockKey, getStockKey,
} from "./selection-presentation" } from "./selection-presentation"
import { SignalDetails } from "./signal-details" import { SignalDetails } from "./signal-details"
@@ -77,9 +77,7 @@ export function SignalRecordList({
<span className="text-xs tabular-nums text-muted-foreground"> <span className="text-xs tabular-nums text-muted-foreground">
J&nbsp; J&nbsp;
<strong className="font-semibold text-foreground"> <strong className="font-semibold text-foreground">
{stock.signals {getStockJValue(stock)}
.map((signal) => getSignalJValue(signal.details))
.join(" / ")}
</strong> </strong>
</span> </span>
<Button <Button
@@ -7,7 +7,7 @@ import { PatternScoreSummary } from "./pattern-score"
import { import {
categoryToneClass, categoryToneClass,
getCategoryLabel, getCategoryLabel,
getSignalJValue, getStockJValue,
getStockKey, getStockKey,
} from "./selection-presentation" } from "./selection-presentation"
@@ -81,11 +81,7 @@ export function SignalTable({
<PatternScoreSummary score={stock.score} /> <PatternScoreSummary score={stock.score} />
</td> </td>
<td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground"> <td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground">
{stock.signals.map((signal) => ( {getStockJValue(stock)}
<span key={signal.category} className="block">
{getSignalJValue(signal.details)}
</span>
))}
</td> </td>
<td className="px-3 text-right align-middle font-medium tabular-nums text-foreground"> <td className="px-3 text-right align-middle font-medium tabular-nums text-foreground">
¥ {stock.close.toFixed(2)} ¥ {stock.close.toFixed(2)}
@@ -153,11 +153,15 @@ describe("SelectionResultsPage", () => {
expect(within(table).getByText("原始 B1")).toBeInTheDocument() expect(within(table).getByText("原始 B1")).toBeInTheDocument()
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument() expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
expect(within(table).getByText("12")).toBeInTheDocument() expect(within(table).getByText("12")).toBeInTheDocument()
expect(within(table).getByText("13")).toBeInTheDocument() expect(within(table).queryByText("13")).not.toBeInTheDocument()
expect(within(table).getByText("平安银行")).toBeInTheDocument() expect(within(table).getByText("平安银行")).toBeInTheDocument()
expect(within(table).getByText("86.40 分")).toBeInTheDocument() expect(within(table).getByText("86.40 分")).toBeInTheDocument()
expect(within(table).getByText("华纳药厂")).toBeInTheDocument() expect(within(table).getByText("华纳药厂")).toBeInTheDocument()
const mobileRecord = screen.getAllByRole("article")[0]
expect(within(mobileRecord).getByText("12")).toBeInTheDocument()
expect(within(mobileRecord).queryByText("13")).not.toBeInTheDocument()
const detailPanel = screen.getByRole("complementary", { const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情", name: "当前股票详情",
}) })