diff --git a/.trellis/tasks/08-27-onechartlab-research/check.jsonl b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/check.jsonl similarity index 68% rename from .trellis/tasks/08-27-onechartlab-research/check.jsonl rename to .trellis/tasks/archive/2026-09/08-27-onechartlab-research/check.jsonl index 7ad62cf..5c18b2a 100644 --- a/.trellis/tasks/08-27-onechartlab-research/check.jsonl +++ b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/check.jsonl @@ -5,3 +5,7 @@ {"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/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 结果。"} diff --git a/.trellis/tasks/08-27-onechartlab-research/design.md b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/design.md similarity index 74% rename from .trellis/tasks/08-27-onechartlab-research/design.md rename to .trellis/tasks/archive/2026-09/08-27-onechartlab-research/design.md index fa386e9..d7d46dd 100644 --- a/.trellis/tasks/08-27-onechartlab-research/design.md +++ b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/design.md @@ -32,3 +32,11 @@ ## 验证与回滚 最终检查逐条核对 PRD 验收标准、报告内引用的可访问性、事实/推断分级、两模块的一致术语和复现说明完整性。报告是新增 Markdown 文件,回滚只需移除新增报告和任务底稿,不影响运行时系统。 + +## 板块排名页交互迭代 + +排名接口继续沿用现有 `page` / `page_size` 服务端契约,前端 query 层新增无限查询封装,以首屏页为起点,根据已加载条数和响应 `total` 推导下一页。页面只消费 React Query 管理的分页集合,不把服务端响应复制到 Zustand 或组件本地状态;筛选条件参与 query key,条件变化自然切换到新的首批数据。 + +表格自身保持唯一纵向滚动容器和 sticky 表头,在滚动位置接近底部时调用 `fetchNextPage`。加载下一批时在表格底部提供紧凑状态,加载失败时保留已加载行并提供可重试操作,全部加载后不显示传统页码或每页条数控制。 + +移除 `RadarStatusSummary` 及其静态标题、免责声明、版本和发布元数据块。部分发布、后台刷新、刷新失败、首次加载失败和无数据仍属于操作性状态,继续以紧凑提示或状态容器呈现,不删除关键异常反馈。 diff --git a/.trellis/tasks/08-27-onechartlab-research/implement.jsonl b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/implement.jsonl similarity index 67% rename from .trellis/tasks/08-27-onechartlab-research/implement.jsonl rename to .trellis/tasks/archive/2026-09/08-27-onechartlab-research/implement.jsonl index f2042ee..307de89 100644 --- a/.trellis/tasks/08-27-onechartlab-research/implement.jsonl +++ b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/implement.jsonl @@ -5,3 +5,7 @@ {"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/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": "实现后需更新行为测试并通过前端质量命令。"} diff --git a/.trellis/tasks/08-27-onechartlab-research/implement.md b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/implement.md similarity index 77% rename from .trellis/tasks/08-27-onechartlab-research/implement.md rename to .trellis/tasks/archive/2026-09/08-27-onechartlab-research/implement.md index 1a76c49..ff59f05 100644 --- a/.trellis/tasks/08-27-onechartlab-research/implement.md +++ b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/implement.md @@ -10,6 +10,10 @@ - [x] 编写 `docs/research/onechartlab-sector-capital-radar.md`,覆盖输入、加工链路、排序/打分/映射、输出信号、更新和异常边界、复现伪代码及证据置信度。 - [x] 编写 `docs/research/onechartlab-macro-timing.md`,覆盖输入、状态定义、阈值/状态机、滞后与前视风险、资产解释、复现伪代码及证据置信度。 - [x] 执行最终内容检查,确认每条关键技术结论有出处或推断标签,两份报告可以独立阅读且不包含收益承诺或未经确认的精确参数。 +- [x] 在 sector radar query 层加入基于现有分页接口的无限查询,并用单元测试覆盖 query key、取消信号和下一页判定。 +- [x] 将板块排名表改为滚动接近底部时加载下一页,移除共享分页器接入,确保筛选变化时列表重置且下一批失败不会清空已加载行。 +- [x] 删除 `RadarStatusSummary` 大块描述内容,把部分发布等必要状态保留为紧凑提示,并更新页面行为测试。 +- [x] 运行前端 format、lint、typecheck、相关 Vitest、完整测试与 build,随后进行可见页面检查。 ## 验证命令与人工检查 @@ -23,6 +27,15 @@ git diff --check 人工核对报告中的关键 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 +``` + ## 风险与停止条件 如果网站需要绕过登录、付费、验证码、地域限制或反爬机制才能继续,停止该取证路径并记录缺口。若压缩/混淆脚本只能支持候选实现而不能证明算法,降级为推断。若公开数据只给出最终分值,报告提供可实施的验证实验,而不虚构原始公式。 diff --git a/.trellis/tasks/08-27-onechartlab-research/prd.md b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/prd.md similarity index 85% rename from .trellis/tasks/08-27-onechartlab-research/prd.md rename to .trellis/tasks/archive/2026-09/08-27-onechartlab-research/prd.md index 9d4256b..2265f5e 100644 --- a/.trellis/tasks/08-27-onechartlab-research/prd.md +++ b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/prd.md @@ -35,10 +35,14 @@ - [x] 两份报告不存在投资收益承诺,并明确其用途是技术与产品逻辑研究,不构成投资建议。 - [x] 生成一份 Tushare 数据需求研究文档,能够作为后续采集、落库和独立重算的接口清单与实施边界。 - [x] Tushare 文档明确区分页面已暴露的确定接口、为复现建议补充的接口和仍需实测/开通权限确认的接口;关键字段及权限信息引用官方来源。 +- [x] 板块资金雷达排名页取消底部分页器,首批数据展示后可在表格滚动区接近底部时继续加载下一批,直到当前筛选条件下全部结果加载完成。 +- [x] 切换交易日、板块类型、指标视角、榜单范围或搜索条件后,无限列表从首批结果重新开始,不混入旧筛选条件的数据。 +- [x] 移除排名表上方包含标题、免责声明、实现标签、版本号和发布元数据的整块描述性卡片,不再用大面积静态说明挤占榜单空间。 +- [x] 刷新失败、后台刷新、部分发布、加载失败和无数据等操作性状态仍能以紧凑且可访问的方式向用户表达。 ## Out of Scope - 绕过登录、会员、验证码、地域限制、反爬机制或其他访问控制。 - 对未公开的后端源码、私有数据源或专有算法作确定性归因。 -- 开发、回测或上线等价策略,评估真实交易收益,或提供个性化投资建议。 +- 开发、回测或上线等价策略,评估真实交易收益,或提供个性化投资建议;本轮仅迭代既有板块资金雷达页面的列表交互与信息密度。 - 修改 OneChartLab 网站或向其作者、用户发送消息。 diff --git a/.trellis/tasks/08-27-onechartlab-research/research/author-video-evidence.md b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/research/author-video-evidence.md similarity index 100% rename from .trellis/tasks/08-27-onechartlab-research/research/author-video-evidence.md rename to .trellis/tasks/archive/2026-09/08-27-onechartlab-research/research/author-video-evidence.md diff --git a/.trellis/tasks/08-27-onechartlab-research/research/macro-timing-evidence.md b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/research/macro-timing-evidence.md similarity index 100% rename from .trellis/tasks/08-27-onechartlab-research/research/macro-timing-evidence.md rename to .trellis/tasks/archive/2026-09/08-27-onechartlab-research/research/macro-timing-evidence.md diff --git a/.trellis/tasks/08-27-onechartlab-research/research/sector-capital-radar-evidence.md b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/research/sector-capital-radar-evidence.md similarity index 100% rename from .trellis/tasks/08-27-onechartlab-research/research/sector-capital-radar-evidence.md rename to .trellis/tasks/archive/2026-09/08-27-onechartlab-research/research/sector-capital-radar-evidence.md diff --git a/.trellis/tasks/08-27-onechartlab-research/task.json b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/task.json similarity index 90% rename from .trellis/tasks/08-27-onechartlab-research/task.json rename to .trellis/tasks/archive/2026-09/08-27-onechartlab-research/task.json index cf3535e..39d2d2e 100644 --- a/.trellis/tasks/08-27-onechartlab-research/task.json +++ b/.trellis/tasks/archive/2026-09/08-27-onechartlab-research/task.json @@ -3,7 +3,7 @@ "name": "onechartlab-research", "title": "OneChartLab 板块资金雷达与宏观择时技术调研", "description": "", - "status": "in_progress", + "status": "completed", "dev_type": null, "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "yuxuanhui", "assignee": "yuxuanhui", "createdAt": "2026-08-27", - "completedAt": null, + "completedAt": "2026-09-01", "branch": null, "base_branch": "main", "worktree_path": null, diff --git a/.trellis/workspace/yuxuanhui/index.md b/.trellis/workspace/yuxuanhui/index.md index 7e1de22..ac039c8 100644 --- a/.trellis/workspace/yuxuanhui/index.md +++ b/.trellis/workspace/yuxuanhui/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 12 -- **Last Active**: 2026-08-31 +- **Total Sessions**: 13 +- **Last Active**: 2026-09-01 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~313 | Active | +| `journal-1.md` | ~335 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 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` | | 10 | 2026-08-29 | 完成板块资金雷达 Tushare 独立生产 MVP | `3789008`, `284c480`, `d9bae72`, `efc4c3d`, `8e96e64`, `23493fa`, `2fd16e5` | `codex/zijin` | diff --git a/.trellis/workspace/yuxuanhui/journal-1.md b/.trellis/workspace/yuxuanhui/journal-1.md index 22599b3..f59ecdd 100644 --- a/.trellis/workspace/yuxuanhui/journal-1.md +++ b/.trellis/workspace/yuxuanhui/journal-1.md @@ -311,3 +311,25 @@ ### Status [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** diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts index 3166f20..f872b26 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest" const useQuery = vi.hoisted(() => vi.fn()) +const useInfiniteQuery = vi.hoisted(() => vi.fn()) const api = vi.hoisted(() => ({ getSectorRadarDates: vi.fn(), getSectorRadarRankings: vi.fn(), })) -vi.mock("@tanstack/react-query", () => ({ useQuery })) +vi.mock("@tanstack/react-query", () => ({ useInfiniteQuery, useQuery })) vi.mock("./sector-radar.api", () => api) import { @@ -32,9 +33,10 @@ describe("sector radar query hooks", () => { beforeEach(() => { vi.clearAllMocks() 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(sectorRadarRankingsQueryKey(query)).toEqual([ "sectorRadar", @@ -46,9 +48,11 @@ describe("sector radar query hooks", () => { 3, "bottom", "银行", - 2, 50, ]) + expect( + sectorRadarRankingsQueryKey({ ...query, page: query.page + 1 }), + ).toEqual(sectorRadarRankingsQueryKey(query)) }) 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 { queryFn: (context: { signal: AbortSignal }) => unknown } - const rankingsOptions = useQuery.mock.calls[1]?.[0] as { - queryFn: (context: { signal: AbortSignal }) => unknown + const rankingsOptions = useInfiniteQuery.mock.calls[0]?.[0] as { + queryFn: (context: { pageParam: number; signal: AbortSignal }) => unknown } const signal = new AbortController().signal await datesOptions.queryFn({ signal }) - await rankingsOptions.queryFn({ signal }) + await rankingsOptions.queryFn({ pageParam: 3, 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() }) }) diff --git a/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts index 37d4d05..50f8c6a 100644 --- a/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts +++ b/zhixing-web/src/features/sector-radar/api/sector-radar.query.ts @@ -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 type { RadarRankingsQuery } from "./sector-radar.types" +import type { + RadarRankingsQuery, + RadarRankingsResponse, +} from "./sector-radar.types" export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const @@ -16,7 +23,6 @@ export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) => query.rankChangeDays, query.side, query.search ?? "", - query.page, query.pageSize, ] as const @@ -28,8 +34,33 @@ export function useSectorRadarDates() { } export function useSectorRadarRankings(query: RadarRankingsQuery) { - return useQuery({ - queryFn: ({ signal }) => getSectorRadarRankings(query, signal), + return useInfiniteQuery< + RadarRankingsResponse, + Error, + InfiniteData, + ReturnType, + 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), }) } diff --git a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx index 79c7f23..df7c126 100644 --- a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx +++ b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx @@ -14,6 +14,7 @@ const useSectorRadarDates = vi.fn() const useSectorRadarRankings = vi.fn() const refetchDates = vi.fn() const refetchRankings = vi.fn() +const fetchNextPage = vi.fn() let routeSearch: SectorRadarRouteSearch @@ -114,6 +115,21 @@ const rankingsResponse: RadarRankingsResponse = { ], } +function rankingQueryResult(overrides: Record = {}) { + return { + data: { pageParams: [1], pages: [rankingsResponse] }, + fetchNextPage, + hasNextPage: false, + isError: false, + isFetchNextPageError: false, + isFetching: false, + isFetchingNextPage: false, + isPending: false, + refetch: refetchRankings, + ...overrides, + } +} + describe("SectorRadarPage", () => { beforeEach(() => { routeSearch = { @@ -134,36 +150,43 @@ describe("SectorRadarPage", () => { isPending: false, refetch: refetchDates, }) - useSectorRadarRankings.mockReturnValue({ - data: rankingsResponse, - isError: false, - isFetching: false, - isPending: false, - refetch: refetchRankings, - }) + useSectorRadarRankings.mockReturnValue(rankingQueryResult()) navigate.mockReset() refetchDates.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() - expect(screen.getAllByText("知行独立实现").length).toBeGreaterThan(0) - expect(screen.getByText("zhixing_amount_net_bn_v1")).toBeInTheDocument() expect(screen.getByText("12.5 亿元")).toBeInTheDocument() expect(screen.getByText("机器人")).toBeInTheDocument() expect(screen.getByText("样本有限")).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() fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), { target: { value: "机器人" }, }) - fireEvent.click(screen.getByRole("button", { name: "第 1 页" })) const searchCall = navigate.mock.calls[0]?.[0].search as ( 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() + 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() + + 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() + + 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() + + 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", () => { routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 } - useSectorRadarRankings.mockReturnValue({ - data: { - ...rankingsResponse, - view: "rank_change", - rank_change_days: 5, - rows: rankingsResponse.rows.map((row, index) => ({ - ...row, - rank_change_days: 5, - rank_change: index === 0 ? 3 : null, - })), - }, - isError: false, - isFetching: false, - isPending: false, - refetch: refetchRankings, - }) + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ + data: { + pageParams: [1], + pages: [ + { + ...rankingsResponse, + view: "rank_change", + rank_change_days: 5, + rows: rankingsResponse.rows.map((row, index) => ({ + ...row, + rank_change_days: 5, + rank_change: index === 0 ? 3 : null, + })), + }, + ], + }, + }), + ) render() @@ -230,13 +341,9 @@ describe("SectorRadarPage", () => { }) it("keeps the last response visible while React Query refreshes it", () => { - useSectorRadarRankings.mockReturnValue({ - data: rankingsResponse, - isError: false, - isFetching: true, - isPending: false, - refetch: refetchRankings, - }) + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ isFetching: true }), + ) render() @@ -247,13 +354,9 @@ describe("SectorRadarPage", () => { }) it("keeps stale data visible when a background refresh fails", () => { - useSectorRadarRankings.mockReturnValue({ - data: rankingsResponse, - isError: true, - isFetching: false, - isPending: false, - refetch: refetchRankings, - }) + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ isError: true }), + ) render() @@ -272,8 +375,12 @@ describe("SectorRadarPage", () => { }) useSectorRadarRankings.mockReturnValue({ data: undefined, + fetchNextPage, + hasNextPage: false, isError: false, + isFetchNextPageError: false, isFetching: true, + isFetchingNextPage: false, isPending: true, refetch: refetchRankings, }) @@ -289,8 +396,12 @@ describe("SectorRadarPage", () => { }) useSectorRadarRankings.mockReturnValue({ data: undefined, + fetchNextPage, + hasNextPage: false, isError: true, + isFetchNextPageError: false, isFetching: false, + isFetchingNextPage: false, isPending: false, refetch: refetchRankings, }) @@ -311,19 +422,22 @@ describe("SectorRadarPage", () => { isPending: false, refetch: refetchDates, }) - useSectorRadarRankings.mockReturnValue({ - data: { - ...rankingsResponse, - status: "no_data", - publication: null, - rows: [], - total: 0, - }, - isError: false, - isFetching: false, - isPending: false, - refetch: refetchRankings, - }) + useSectorRadarRankings.mockReturnValue( + rankingQueryResult({ + data: { + pageParams: [1], + pages: [ + { + ...rankingsResponse, + status: "no_data", + publication: null, + rows: [], + total: 0, + }, + ], + }, + }), + ) rerender() expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument() }) diff --git a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx index 53710e1..38804b8 100644 --- a/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx +++ b/zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx @@ -1,5 +1,5 @@ import { AlertTriangle, Database, RefreshCw } from "lucide-react" -import { useEffect } from "react" +import { useRef } from "react" import { useNavigate, useSearch } from "@tanstack/react-router" import { PageLayout } from "@/app/layout/page-layout" @@ -13,6 +13,7 @@ import type { RadarPublication, RadarRankingRow, RadarRankingsQuery, + RadarRankingsResponse, SectorRadarRouteSearch, } from "@/features/sector-radar/api/sector-radar.types" import { Badge } from "@/shared/ui/badge" @@ -25,7 +26,6 @@ import { CardTitle, } from "@/shared/ui/card" import { Input } from "@/shared/ui/input" -import { Pagination } from "@/shared/ui/pagination" import { Select, SelectContent, @@ -36,8 +36,6 @@ import { } from "@/shared/ui/select" import { Skeleton } from "@/shared/ui/skeleton" -const PAGE_SIZE_OPTIONS = [10, 20, 50] as const - const sectorTypeOptions = [ { label: "概念板块", value: "concept" }, { label: "行业板块", value: "industry" }, @@ -83,16 +81,9 @@ export function SectorRadarPage() { } const dates = useSectorRadarDates() const rankings = useSectorRadarRankings(query) - const total = rankings.data?.total ?? 0 - const pageCount = Math.max(1, Math.ceil(total / search.pageSize)) - - useEffect(() => { - if (rankings.data?.status === "success" && search.page > pageCount) { - void navigate({ - search: (previous) => ({ ...previous, page: pageCount }), - }) - } - }, [navigate, pageCount, rankings.data?.status, search.page]) + const rankingPages = rankings.data?.pages ?? [] + const firstRankingPage = rankingPages[0] + const rankingRows = rankingPages.flatMap((page) => page.rows) function updateSearch(next: Partial) { void navigate({ search: (previous) => ({ ...previous, ...next }) }) @@ -100,13 +91,17 @@ export function SectorRadarPage() { const loading = dates.isPending || rankings.isPending const refreshing = - !loading && (Boolean(dates.isFetching) || Boolean(rankings.isFetching)) + !loading && + (Boolean(dates.isFetching) || + (Boolean(rankings.isFetching) && !rankings.isFetchingNextPage)) const fatalError = (dates.isError && !dates.data) || (rankings.isError && !rankings.data) const refreshFailed = - !fatalError && (Boolean(dates.isError) || Boolean(rankings.isError)) + !fatalError && + (Boolean(dates.isError) || + (Boolean(rankings.isError) && !rankings.isFetchNextPageError)) const noData = - dates.data?.status === "no_data" || rankings.data?.status === "no_data" + dates.data?.status === "no_data" || firstRankingPage?.status === "no_data" return ( {refreshFailed ? ( ) : refreshing ? ( ) : null} - updateSearch({ page })} - onPageSizeChange={(pageSize) => - updateSearch({ page: 1, pageSize }) - } - page={Math.min(search.page, pageCount)} - pageSize={search.pageSize} - response={rankings.data} + hasNextPage={Boolean(rankings.hasNextPage)} + isFetchNextPageError={rankings.isFetchNextPageError} + isFetchingNextPage={rankings.isFetchingNextPage} + onLoadMore={() => rankings.fetchNextPage()} + response={firstRankingPage} + rows={rankingRows} /> ) : null} @@ -307,17 +299,13 @@ function FilterSelect({ ) } -function RadarStatusSummary({ +function RadarPublicationStatus({ currentAttempt, - disclaimer, lastGood, - metricVersion, publication, }: { currentAttempt: RadarPublication | null - disclaimer: string lastGood: RadarPublication | null - metricVersion: string publication: RadarPublication }) { const degradedAttempt = @@ -325,80 +313,71 @@ function RadarStatusSummary({ currentAttempt.status !== "success" && currentAttempt.publication_id !== lastGood?.publication_id - return ( - - -
-
- 收盘后板块资金排名 - {disclaimer} -
- 知行独立实现 - {metricVersion} -
- {degradedAttempt ? ( -
- {attemptStatusLabel(currentAttempt.status)}:目标交易日{" "} - {currentAttempt.target_trade_date} - ,当前仍展示最近有效发布{" "} - {lastGood?.target_trade_date ?? publication.target_trade_date}。 -
- ) : null} -
- - - - - - - -
- ) -} + if (!degradedAttempt) return null -function SummaryItem({ label, value }: { label: string; value: string }) { return ( -
-

{label}

-

- {value} -

+
+ {attemptStatusLabel(currentAttempt.status)}:目标交易日{" "} + {currentAttempt.target_trade_date},当前仍展示最近有效发布{" "} + {lastGood?.target_trade_date ?? publication.target_trade_date}。
) } function RadarTable({ - onPageChange, - onPageSizeChange, - page, - pageSize, + hasNextPage, + isFetchNextPageError, + isFetchingNextPage, + onLoadMore, response, + rows, }: { - onPageChange: (page: number) => void - onPageSizeChange: (pageSize: number) => void - page: number - pageSize: number - response: Exclude< - ReturnType["data"], - undefined - > + hasNextPage: boolean + isFetchNextPageError: boolean + isFetchingNextPage: boolean + onLoadMore: () => Promise + response: RadarRankingsResponse + rows: RadarRankingRow[] }) { 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 (
-
+
{ + const container = event.currentTarget + const remaining = + container.scrollHeight - + container.scrollTop - + container.clientHeight + if ( + remaining <= 160 && + hasNextPage && + !isFetchingNextPage && + !isFetchNextPageError + ) { + requestLoadMore() + } + }} + role="region" + tabIndex={0} + > - {response.rows.map((row) => ( + {rows.map((row) => ( ))} - {response.rows.length === 0 ? ( + {rows.length === 0 ? (
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名 @@ -436,14 +415,14 @@ function RadarTable({
+ {isFetchingNextPage ? ( +
+ 正在加载更多板块… +
+ ) : null} + {isFetchNextPageError ? ( +
+ 加载更多板块失败,已加载结果仍保留。 + +
+ ) : null}
-
) } @@ -631,15 +622,6 @@ function formatNumber(value: number, maximumFractionDigits: number) { 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"]) { if (status === "running") return "新一期仍在构建" if (status === "partial") return "新一期数据不完整" diff --git a/zhixing-web/src/features/selection/components/selection-presentation.ts b/zhixing-web/src/features/selection/components/selection-presentation.ts index 5cb9d50..f88c6aa 100644 --- a/zhixing-web/src/features/selection/components/selection-presentation.ts +++ b/zhixing-web/src/features/selection/components/selection-presentation.ts @@ -90,6 +90,14 @@ export function getSignalJValue(details: SelectionSignal["details"]) { 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( key: string, value: number | string | null | undefined, diff --git a/zhixing-web/src/features/selection/components/signal-record-list.tsx b/zhixing-web/src/features/selection/components/signal-record-list.tsx index 8a235f3..1b8e6d0 100644 --- a/zhixing-web/src/features/selection/components/signal-record-list.tsx +++ b/zhixing-web/src/features/selection/components/signal-record-list.tsx @@ -8,7 +8,7 @@ import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score" import { categoryToneClass, getCategoryLabel, - getSignalJValue, + getStockJValue, getStockKey, } from "./selection-presentation" import { SignalDetails } from "./signal-details" @@ -77,9 +77,7 @@ export function SignalRecordList({ J  - {stock.signals - .map((signal) => getSignalJValue(signal.details)) - .join(" / ")} + {getStockJValue(stock)}