diff --git a/.trellis/tasks/09-01-optimize-stock-list-radar/check.jsonl b/.trellis/tasks/09-01-optimize-stock-list-radar/check.jsonl new file mode 100644 index 0000000..48f41a7 --- /dev/null +++ b/.trellis/tasks/09-01-optimize-stock-list-radar/check.jsonl @@ -0,0 +1,3 @@ +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端质量门禁与测试规范"} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "组件可访问性与列表交互规范"} +{"file": ".trellis/tasks/09-01-optimize-stock-list-radar/prd.md", "reason": "用户需求与验收标准"} diff --git a/.trellis/tasks/09-01-optimize-stock-list-radar/design.md b/.trellis/tasks/09-01-optimize-stock-list-radar/design.md new file mode 100644 index 0000000..c5e6677 --- /dev/null +++ b/.trellis/tasks/09-01-optimize-stock-list-radar/design.md @@ -0,0 +1,19 @@ +# 技术设计 + +## 边界与组件结构 + +选股模块保留 `SelectionResultsWorkbench` 的筛选、分页、选中状态和详情面板结构,将当前桌面 `SignalTable` 与移动 `SignalRecordList` 的双实现收敛为一套响应式紧凑卡片列表。列表继续位于左侧结果区内部滚动;条目用语义化 `article` 和真实按钮承载选择、展开交互,不改变路由搜索参数或 API 类型。 + +资金雷达继续使用语义化表格与现有无限滚动容器,只删除展示层中的资金覆盖率和质量表头/单元格,并同步空状态 `colSpan`。通过缩小表头和数据行的垂直 padding、减少板块名称与代码间距来提高密度,不改动返回数据类型或质量计算。 + +## 数据流与兼容性 + +两处改动均消费现有响应字段,不改变 query、路由和后端契约。选股卡片仍用 `getStockKey`、`getStockJValue`、`PatternScoreSummary`、`SignalDetails` 和现有 category 样式函数,确保内容与详情行为保持一致。资金雷达保留 `quality` 和 `moneyflow_coverage` 在 TypeScript 响应类型中,仅不展示,以避免形成不必要的接口破坏。 + +## 取舍 + +选股列表采用单一响应式组件,消除桌面/移动两套 DOM 和测试分叉;卡片通过横向信息区和较小间距兼顾密度与窄屏换行。资金雷达保留 table,因为用户仅要求缩减列和行高,表格仍最适合对齐排名数据。 + +## 回滚 + +改动局限于选股展示组件、工作台装配、资金雷达页面及对应测试;如出现视觉或交互回归,可逐文件回退,不涉及数据迁移。 diff --git a/.trellis/tasks/09-01-optimize-stock-list-radar/implement.jsonl b/.trellis/tasks/09-01-optimize-stock-list-radar/implement.jsonl new file mode 100644 index 0000000..96387fd --- /dev/null +++ b/.trellis/tasks/09-01-optimize-stock-list-radar/implement.jsonl @@ -0,0 +1,3 @@ +{"file": ".trellis/spec/frontend/index.md", "reason": "前端开发入口与检查清单"} +{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "组件、样式与可访问性规范"} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "测试与质量门禁,约束卡片列表与雷达表格的断言方式"} diff --git a/.trellis/tasks/09-01-optimize-stock-list-radar/implement.md b/.trellis/tasks/09-01-optimize-stock-list-radar/implement.md new file mode 100644 index 0000000..ba1d87a --- /dev/null +++ b/.trellis/tasks/09-01-optimize-stock-list-radar/implement.md @@ -0,0 +1,10 @@ +# 实施计划 + +1. 将选股结果收敛为单一紧凑卡片列表,保留选中、键盘、展开详情、空状态与内部滚动。 +2. 更新工作台装配,移除 table 展示路径和重复组件引用,并按需要删除不再使用的文件。 +3. 更新选股页面测试,改为断言卡片列表结构、核心字段和交互,不再依赖 table 表头。 +4. 从资金雷达表格移除资金覆盖率与质量两列,修正 `colSpan` 并缩小表头、数据行间距。 +5. 更新资金雷达测试,明确断言精简列不存在而保留百分位和样本;保留无限滚动与排名变化回归覆盖。 +6. 在 `zhixing-web/` 运行目标 Vitest,然后运行 `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`;必要时运行 `pnpm build` 验证产物。 + +风险点是选股卡片按钮嵌套和键盘行为、断点下滚动高度链,以及资金雷达 rank-change 模式的动态列数。实现时以语义结构和现有页面布局约束为准,出现回归时优先回滚对应组件的局部改动。 diff --git a/.trellis/tasks/09-01-optimize-stock-list-radar/prd.md b/.trellis/tasks/09-01-optimize-stock-list-radar/prd.md new file mode 100644 index 0000000..6dce77e --- /dev/null +++ b/.trellis/tasks/09-01-optimize-stock-list-radar/prd.md @@ -0,0 +1,27 @@ +# 优化选股列表与资金雷达看板布局 + +## Goal + +提升选股结果和资金雷达的浏览密度与可读性,让用户在同一屏看到更多条目,并减少不必要的辅助指标占用。 + +## Background and confirmed facts + +当前选股结果在桌面端由 `features/selection/components/signal-table.tsx` 以 `` 展示,移动端另有 `signal-record-list.tsx` 卡片列表;工作台同时挂载两者并通过断点切换。资金雷达页面的 `RadarTable` 表头和行包含“排名百分位、样本、资金覆盖率、质量”等列,行使用较大的垂直 padding。 + +## Requirements + +1. 选股结果在所有屏幕尺寸统一使用可滚动列表,不再渲染 table。每个股票条目以紧凑卡片展示股票名称/代码、信号标签、图形评分、J 值和收盘价,并保留点击选中、键盘可操作和详情展开能力。列表容器应继续支持内部滚动和空结果提示,分页与筛选行为保持不变。 +2. 资金雷达看板保留核心排名、板块、指标值、排名百分位和样本信息,移除“资金覆盖率”和“质量”两列及其单元格内容;表头和数据行改为更紧凑的垂直间距,在不改变滚动加载、错误/空数据状态和排名变化视图的前提下提升可见行数。 +3. 现有筛选、排序、加载更多和选中股票详情的数据契约不变;仅调整展示层结构和样式。 + +## Acceptance Criteria + +- [ ] 选股工作台 DOM 中不再出现 `role="table"`/`
`,桌面和移动宽度均可看到滚动卡片列表;每张卡片展示股票名称、代码、至少一个信号标签(有信号时)、评分、J 值和收盘价。 +- [ ] 选股卡片点击或键盘 Enter/Space 可选中股票,详情面板仍显示当前选中项;“查看详情/收起详情”可展开信号细节。 +- [ ] 资金雷达表头不再包含“资金覆盖率”和“质量”,对应行内容也不再渲染;“排名百分位”和“样本”仍可见。 +- [ ] 资金雷达数据行垂直间距小于当前实现,滚动到底部仍能触发一次加载更多,加载中和失败重试提示保持可见。 +- [ ] `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test` 在 `zhixing-web/` 下通过。 + +## Out of scope + +不调整后端接口、指标计算、筛选/排序语义、分页参数、详情面板字段或全局主题 token。 diff --git a/.trellis/tasks/09-01-optimize-stock-list-radar/task.json b/.trellis/tasks/09-01-optimize-stock-list-radar/task.json new file mode 100644 index 0000000..c5785e3 --- /dev/null +++ b/.trellis/tasks/09-01-optimize-stock-list-radar/task.json @@ -0,0 +1,26 @@ +{ + "id": "optimize-stock-list-radar", + "name": "optimize-stock-list-radar", + "title": "优化选股列表与资金雷达看板布局", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-09-01", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file 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 41183ff..10488cc 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 @@ -175,8 +175,22 @@ describe("SectorRadarPage", () => { 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.getAllByText("19/20")[0]).toBeInTheDocument() + expect(screen.getAllByText("100%")[0]).toBeInTheDocument() + expect( + screen.getAllByRole("columnheader", { name: "排名百分位" }), + ).toHaveLength(2) + expect(screen.getAllByRole("columnheader", { name: "样本" })).toHaveLength( + 2, + ) + expect( + screen.queryByRole("columnheader", { name: "资金覆盖率" }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole("columnheader", { name: "质量" }), + ).not.toBeInTheDocument() + expect(screen.queryByText("样本有限")).not.toBeInTheDocument() + expect(screen.queryByText("95%")).not.toBeInTheDocument() expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument() expect( screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"), 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 32809e7..84aca9e 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 @@ -9,7 +9,6 @@ import { } from "@/features/sector-radar/api/sector-radar.query" import type { RadarMetricKind, - RadarMetricQuality, RadarMetricUnit, RadarPublication, RadarRankingRow, @@ -18,7 +17,6 @@ import type { RadarView, SectorRadarRouteSearch, } from "@/features/sector-radar/api/sector-radar.types" -import { Badge } from "@/shared/ui/badge" import { Button } from "@/shared/ui/button" import { Card, @@ -398,83 +396,71 @@ function RadarTable({ role="region" tabIndex={0} > -
+
- {Array.from({ length: 12 }, (_, i) => ( + {Array.from({ length: 8 }, (_, i) => ( ))} - - - - - - - - - - @@ -491,8 +477,8 @@ function RadarTable({ {rowCount === 0 ? ( @@ -582,42 +568,49 @@ function RankCells({ row?: RadarRankingRow align: "left" | "right" }) { - if (!row) + const rankClass = align === "right" ? "text-right" : "text-left" + const rankCell = row ? ( + + ) : ( + + ) + const percentileCell = row ? ( + + ) : ( + + ) : ( + - - - - - + {rankCell} + {percentileCell} + {sampleCell} ) } @@ -652,7 +645,7 @@ function MetricCell({ : "text-foreground bg-muted/30" return ( @@ -673,19 +666,6 @@ function formatDirectionalMetric( return `${direction === "in" ? "+" : "−"}${formatted}` } -function QualityBadge({ quality }: { quality: RadarMetricQuality }) { - if (quality === "available") return 可用 - if (quality === "available_limited_sample") - return ( - - 样本有限 - - ) - return 不可用 -} function RadarLoading() { return ( @@ -768,9 +748,6 @@ function formatRankChange(value: number | null) { 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 }) } diff --git a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx index cd23357..df25c24 100644 --- a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx +++ b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx @@ -26,7 +26,6 @@ import { signalCategoryOptions, type SignalCategoryFilter, } from "./selection-presentation" -import { SignalTable } from "./signal-table" const PAGE_SIZE_OPTIONS = [5, 10, 15] as const const SCORE_SORT_OPTIONS: ReadonlyArray<{ @@ -195,15 +194,11 @@ export function SelectionResultsWorkbench({
- setSelectedKey(getStockKey(stock))} - selectedKey={selectedStock ? getStockKey(selectedStock) : null} - stocks={currentStocks} - /> setSelectedKey(getStockKey(stock))} onToggleExpanded={handleToggleExpanded} + selectedKey={selectedStock ? getStockKey(selectedStock) : null} stocks={currentStocks} />
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 1b8e6d0..cd7d0c2 100644 --- a/zhixing-web/src/features/selection/components/signal-record-list.tsx +++ b/zhixing-web/src/features/selection/components/signal-record-list.tsx @@ -1,7 +1,9 @@ import { ChevronDown, ChevronUp } from "lucide-react" +import type { KeyboardEvent } from "react" import { Badge } from "@/shared/ui/badge" import { Button } from "@/shared/ui/button" +import { cn } from "@/shared/ui/utils" import type { SelectionStockResult } from "../api/selection.types" import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score" @@ -15,6 +17,7 @@ import { SignalDetails } from "./signal-details" interface SignalRecordListProps { stocks: SelectionStockResult[] + selectedKey: string | null expandedKeys: ReadonlySet onSelect: (stock: SelectionStockResult) => void onToggleExpanded: (stock: SelectionStockResult) => void @@ -22,67 +25,86 @@ interface SignalRecordListProps { export function SignalRecordList({ stocks, + selectedKey, expandedKeys, onSelect, onToggleExpanded, }: SignalRecordListProps) { + function handleSelectKeyDown( + event: KeyboardEvent, + stock: SelectionStockResult, + ) { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + onSelect(stock) + } + if (stocks.length === 0) { return ( -
+
没有符合当前筛选条件的信号。
) } return ( -
- {stocks.map((stock) => { - const key = getStockKey(stock) - const expanded = expandedKeys.has(key) - return ( -
- -
- -
-
-
- {stock.signals.map((signal) => ( - - {getCategoryLabel(signal.category)} - - ))} -
-
- - J  - - {getStockJValue(stock)} - - +
+
-
- {expanded ? ( -
-
-

- 图形相似度评分 -

- -
- {stock.signals.map((signal) => ( -
+ {expanded ? ( +
+

- {getCategoryLabel(signal.category)} + 图形相似度评分

- +
- ))} -
- ) : null} -
- ) - })} + {stock.signals.map((signal) => ( +
+

+ {getCategoryLabel(signal.category)} +

+ +
+ ))} +
+ ) : null} + + ) + })} +
) } diff --git a/zhixing-web/src/features/selection/components/signal-table.tsx b/zhixing-web/src/features/selection/components/signal-table.tsx deleted file mode 100644 index 62dd499..0000000 --- a/zhixing-web/src/features/selection/components/signal-table.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import type { KeyboardEvent } from "react" - -import { Badge } from "@/shared/ui/badge" - -import type { SelectionStockResult } from "../api/selection.types" -import { PatternScoreSummary } from "./pattern-score" -import { - categoryToneClass, - getCategoryLabel, - getStockJValue, - getStockKey, -} from "./selection-presentation" - -interface SignalTableProps { - stocks: SelectionStockResult[] - selectedKey: string | null - onSelect: (stock: SelectionStockResult) => void -} - -export function SignalTable({ - stocks, - selectedKey, - onSelect, -}: SignalTableProps) { - function handleRowKeyDown( - event: KeyboardEvent, - stock: SelectionStockResult, - ) { - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - onSelect(stock) - } - - return ( -
-
{response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
资金进攻榜 TOP 10% {rankChangeView ? "排名变化" : response.definition.label} BOTTOM 10% 资金撤离榜
+ 排名 / 板块 + 排名百分位 + 样本 - 资金覆盖率 - - 质量 - {rankChangeView ? "排名上升" : "流入"} {rankChangeView ? "排名下降" : "流出"} - 质量 - - 资金覆盖率 - + 样本 + 排名百分位 + 排名 / 板块
没有符合当前筛选条件的板块。 + {row.rank_position ?? "—"} + / + {row.sector_name} + + {row.sector_code} + + — + {row.rank_percentile === null + ? "—" + : `${formatNumber(row.rank_percentile, 2)}%`} + + ) + const sampleCell = row ? ( + + {row.valid_sample_count}/{row.member_count} + + ) + if (align === "right") { return ( <> - — - - - + {sampleCell} + {percentileCell} + {rankCell} ) + } return ( <> - - {row.rank_position ?? "—"} - / - {row.sector_name} - - {row.sector_code} - - - {row.rank_percentile === null - ? "—" - : `${formatNumber(row.rank_percentile, 2)}%`} - - {row.valid_sample_count}/{row.member_count} - - {formatCoverage(row.moneyflow_coverage)} - - - {value}
- - - - - - - - - - - - {stocks.length > 0 ? ( - stocks.map((stock) => { - const selected = selectedKey === getStockKey(stock) - return ( - onSelect(stock)} - onKeyDown={(event) => handleRowKeyDown(event, stock)} - tabIndex={0} - > - - - - - - - ) - }) - ) : ( - - - - )} - -
知行 B1 命中信号明细
股票信号类型图形评分J 值收盘价
-

{stock.name}

-

- {stock.ts_code} -

-
-
- {stock.signals.map((signal) => ( - - {getCategoryLabel(signal.category)} - - ))} -
-
- - - {getStockJValue(stock)} - - ¥ {stock.close.toFixed(2)} -
- 没有符合当前筛选条件的信号。 -
- - ) -} diff --git a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx index d655b89..8491f69 100644 --- a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx +++ b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx @@ -143,31 +143,18 @@ describe("SelectionResultsPage", () => { expect(screen.getByText("执行完成")).toBeInTheDocument() expect(screen.queryByText("目标股票数")).not.toBeInTheDocument() - const table = screen.getByRole("table") - expect(table).toBeInTheDocument() - expect( - within(table).getByRole("columnheader", { name: "信号类型" }), - ).toBeInTheDocument() - expect( - within(table).getByRole("columnheader", { name: "J 值" }), - ).toBeInTheDocument() - expect( - within(table).getByRole("columnheader", { name: "图形评分" }), - ).toBeInTheDocument() - expect( - within(table).queryByRole("columnheader", { name: "关键详情" }), - ).not.toBeInTheDocument() - expect(within(table).getByText("原始 B1")).toBeInTheDocument() - expect(within(table).getByText("回踩白线 B")).toBeInTheDocument() - expect(within(table).getByText("12")).toBeInTheDocument() - expect(within(table).queryByText("13")).not.toBeInTheDocument() - expect(within(table).getByText("平安银行")).toBeInTheDocument() - expect(within(table).getByText("86.40 分")).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() + expect(screen.queryByRole("table")).not.toBeInTheDocument() + const record = screen.getByRole("article") + expect(within(record).getByText("平安银行")).toBeInTheDocument() + expect(within(record).getByText("000001.SZ")).toBeInTheDocument() + expect(within(record).getByText("原始 B1")).toBeInTheDocument() + expect(within(record).getByText("回踩白线 B")).toBeInTheDocument() + expect(within(record).getByText("86.40 分")).toBeInTheDocument() + expect(within(record).getByText("12")).toBeInTheDocument() + expect(within(record).queryByText("13")).not.toBeInTheDocument() + expect(within(record).getByText("¥ 10.50")).toBeInTheDocument() + expect(within(record).getByText("华纳药厂")).toBeInTheDocument() + expect(screen.getByRole("button", { name: /平安银行/ })).toBeInTheDocument() const detailPanel = screen.getByRole("complementary", { name: "当前股票详情", @@ -249,9 +236,9 @@ describe("SelectionResultsPage", () => { render() expect(screen.getAllByText(expected).length).toBeGreaterThan(0) - const table = screen.getByRole("table") - expect(within(table).getByText("原始 B1")).toBeInTheDocument() - expect(within(table).getByText("12")).toBeInTheDocument() + const record = screen.getByRole("article") + expect(within(record).getByText("原始 B1")).toBeInTheDocument() + expect(within(record).getByText("12")).toBeInTheDocument() if (score?.status === "below_threshold") { const detailPanel = screen.getByRole("complementary", { name: "当前股票详情", @@ -558,16 +545,27 @@ describe("SelectionResultsPage", () => { fireEvent.change(screen.getByLabelText("搜索命中股票"), { target: { value: "招商" }, }) - const table = screen.getByRole("table") - expect(within(table).getByText("招商银行")).toBeInTheDocument() - fireEvent.click(within(table).getByText("招商银行")) + const extraRecord = screen + .getAllByRole("article") + .find((article) => within(article).queryByText("招商银行")) + expect(extraRecord).toBeTruthy() + if (!extraRecord) { + throw new Error("expected 招商银行 card to be visible") + } + const selectButton = within(extraRecord).getByRole("button", { + name: /招商银行/, + }) + fireEvent.keyDown(selectButton, { key: "Enter" }) expect( within( screen.getByRole("complementary", { name: "当前股票详情" }), ).getByText("招商银行"), ).toBeInTheDocument() - const expandButton = screen.getAllByRole("button", { name: "查看详情" })[1] + fireEvent.keyDown(selectButton, { key: " " }) + const expandButton = within(extraRecord).getByRole("button", { + name: "查看详情", + }) fireEvent.click(expandButton) expect(expandButton).toHaveAttribute("aria-expanded", "true") expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)