diff --git a/.trellis/tasks/08-10-selection-results-filter-state-ui/check.jsonl b/.trellis/tasks/08-10-selection-results-filter-state-ui/check.jsonl new file mode 100644 index 0000000..1aaedb2 --- /dev/null +++ b/.trellis/tasks/08-10-selection-results-filter-state-ui/check.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/frontend/index.md","reason":"检查前端改动是否仍遵守 feature/shared 边界、路由和同源 API 约束。"} +{"file":".trellis/spec/frontend/component-guidelines.md","reason":"检查 Select、DatePicker、抽屉入口、语义控件和 Tailwind 组件组合。"} +{"file":".trellis/spec/frontend/state-management.md","reason":"检查日期、URL search、抽屉局部状态与服务器结果的所有权。"} +{"file":".trellis/spec/frontend/type-safety.md","reason":"检查 Base UI/DayPicker props、日期转换和 SelectionResults 类型边界。"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"执行最终格式、lint、类型、测试、构建和审查门禁。"} diff --git a/.trellis/tasks/08-10-selection-results-filter-state-ui/design.md b/.trellis/tasks/08-10-selection-results-filter-state-ui/design.md new file mode 100644 index 0000000..e4abb82 --- /dev/null +++ b/.trellis/tasks/08-10-selection-results-filter-state-ui/design.md @@ -0,0 +1,93 @@ +# 选股结果页筛选与状态入口技术设计 + +## 1. Boundaries + +本任务只修改 `zhixing-web` 前端: + +- `shared/ui` 提供基于 `@base-ui/react` 的通用 `Select`、`Popover`、`Calendar`、`DatePicker` 组件。 +- `features/selection` 负责选项、URL search 更新、目标日期字符串转换、结果状态和抽屉入口布局。 +- `SelectionResultsPage` 继续拥有服务器查询、执行/重执行状态和抽屉开关;`SelectionResultsWorkbench` 负责信号列表顶部的入口和筛选区。 +- `ExecutionStatusDrawer` 负责执行摘要和未完成评估的按需展示,不请求数据、不修改 URL。 + +不改变后端 API、React Query query key、路由路径、`SelectionResults` 类型契约和策略执行流程。 + +## 2. Component Shape + +```text +SelectionResultsPage +├─ PageLayout +│ ├─ actions: ExecutionToolbar +│ │ ├─ DatePicker +│ │ ├─ Select (strategy) +│ │ └─ Button (execute / rerun) +│ └─ children: result states +│ └─ SelectionResultsWorkbench +│ ├─ signal-list header +│ │ ├─ Input (search) +│ │ ├─ Select (category) +│ │ └─ Button (execution status) +│ ├─ SignalTable / SignalRecordList +│ └─ Pagination +└─ ExecutionStatusDrawer + ├─ execution summary metrics + └─ IncompleteEvaluationTable +``` + +移除页面级 `ResultMetrics`,避免 `PageLayout.metrics` 形成中间常驻区域。执行状态按钮移动到 `SelectionResultsWorkbench` 的列表 header,页面通过 props 传入 `onOpenExecutionStatus`、`drawerOpen` 和 `triggerRef`,不把开关状态放入 Zustand。 + +## 3. Shared UI Design + +### Select + +`shared/ui/select.tsx` 对 `@base-ui/react/select` 的 Root、Trigger、Value、Portal、Positioner、Popup、List、Item、ItemIndicator 和滚动箭头做样式封装。对外保留 Base UI props、`className` 和 `data-slot`,提供可组合的 `SelectGroup`、`SelectLabel`、`SelectSeparator` 等导出。Popup 使用 portal 和 positioner,确保列表不受页面 bounded workspace 的 `overflow` 影响;`data-highlighted`、`data-selected`、`data-disabled` 和 open/closed 状态提供明确反馈。 + +### Popover / Calendar / DatePicker + +`DatePicker` 按官方 shadcn/ui Base UI Date Picker 的组合方式实现:`Popover` 负责定位和关闭行为,`Calendar` 负责单日选择。`Calendar` 基于 `react-day-picker`,使用项目 `Button`、`cn` 和现有主题 token;`DatePicker` 对外以通用 `Date | undefined` 传值,业务 feature 在边界处把它转换为 `YYYY-MM-DD`,避免 shared 层理解交易日概念。 + +补充 `date-fns` 与 `react-day-picker` 为显式前端依赖。日期转换使用本地年月日构造/格式化,避免直接解析 ISO 日期造成时区前移或后移。 + +## 4. Data Flow and State + +```text +DatePicker + └─ SelectionResultsPage: Date ↔ YYYY-MM-DD + ├─ useSelectionResults(strategy, targetTradeDate, resultQuery) + └─ useTriggerSelectionRun({ target_trade_date }) + +SelectionResultsPage + ├─ resultQuery ← route search (page/pageSize/search/category) + ├─ SelectionResultsWorkbench + │ ├─ Select(category) → navigate(search: { category, page: 1 }) + │ └─ status button → setExecutionStatusDrawerOpen(true) + └─ ExecutionStatusDrawer(result) + ├─ result summary metrics + └─ result.failures +``` + +目标日期继续是页面局部状态;搜索、分类和分页继续是 TanStack Router search 状态。Select 不直接操作 URL,feature handler 负责转换和分页重置。抽屉只接收当前 `displayedResult`,切换日期或开始执行时关闭并清理已有局部执行状态。 + +## 5. Layout and Accessibility + +- `PageLayout.metrics` 置空后,`PageLayout` 的 bounded workspace 只承载执行控件和结果区;结果列表继续沿用现有高度链和内部滚动边界。 +- Workbench header 在桌面端把搜索、分类和结果数量/执行状态按钮放在同一行,在移动端自然换行;所有主要按钮保留至少 `44px` 的移动触控高度。 +- 执行状态按钮使用真实 `Button`,提供 `aria-controls`、`aria-expanded`、`aria-haspopup="dialog"` 和包含状态/失败数的可访问名称。抽屉继续由 Dialog 管理焦点、Escape 和遮罩。 +- Select Trigger 使用真实 button;DatePicker Trigger 使用 Button,并提供 label/placeholder 和 `aria-label`。日历日期按钮使用 `react-day-picker` 的语义和键盘导航。 + +## 6. Compatibility and Rollback + +- API 和路由协议不变,旧结果数据可直接驱动新布局;若新 shared primitive 样式有问题,可只回退控件接线而保留抽屉数据流。 +- 日期依赖只影响前端依赖和 bundle;若日期选择器有兼容问题,保留 `DatePicker` 对外接口并回退其内部实现,不改变页面与 API 的字符串边界。 +- 抽屉摘要与入口位置的回滚点分别位于 `execution-status-drawer.tsx` 和 `selection-results-workbench.tsx`,不会影响 query 或策略执行。 + +## 7. Verification Strategy + +- 页面测试:无常驻 metrics、DatePicker 选择日期、strategy/category Select 更新、状态入口出现、抽屉摘要/失败表格/空状态、关闭焦点恢复。 +- shared UI 测试或页面行为测试:Select 的可见选项和键盘语义、DatePicker 的按钮和选中日期可见性。 +- 质量门禁:`pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build`。 +- 浏览器 mock:检查 1440×900、390×844 下筛选区域、信号列表、DatePicker/Select popup 和右侧抽屉不产生页面级水平溢出。 + +## 8. References + +- shadcn/ui Base Select: https://ui.shadcn.com/docs/components/base/select +- shadcn/ui Base Date Picker: https://ui.shadcn.com/docs/components/base/date-picker diff --git a/.trellis/tasks/08-10-selection-results-filter-state-ui/implement.jsonl b/.trellis/tasks/08-10-selection-results-filter-state-ui/implement.jsonl new file mode 100644 index 0000000..4467a74 --- /dev/null +++ b/.trellis/tasks/08-10-selection-results-filter-state-ui/implement.jsonl @@ -0,0 +1,5 @@ +{"file":".trellis/spec/frontend/index.md","reason":"前端 feature/shared 边界、现有质量入口和 API/路由约束。"} +{"file":".trellis/spec/frontend/component-guidelines.md","reason":"Select、DatePicker、Drawer、组合组件、Tailwind token 和可访问性交互规范。"} +{"file":".trellis/spec/frontend/state-management.md","reason":"保留 URL search、页面局部日期/抽屉状态和 TanStack Query 的状态边界。"} +{"file":".trellis/spec/frontend/type-safety.md","reason":"shared primitive props、日期值转换和现有 SelectionResults 类型的严格 TypeScript 约束。"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"页面行为测试、可访问性审查和格式/lint/typecheck/test/build 质量门禁。"} diff --git a/.trellis/tasks/08-10-selection-results-filter-state-ui/implement.md b/.trellis/tasks/08-10-selection-results-filter-state-ui/implement.md new file mode 100644 index 0000000..602b859 --- /dev/null +++ b/.trellis/tasks/08-10-selection-results-filter-state-ui/implement.md @@ -0,0 +1,75 @@ +# 选股结果页筛选与状态入口执行计划 + +## Scope + +只修改 `zhixing-web` 前端和本任务规划/验证产物;不修改后端接口、数据库、策略计算或现有路由协议。 + +## Ordered Checklist + +### 1. 开发前上下文 + +- [x] 运行 `task.py start` 将任务切换为 `in_progress`。 +- [x] 读取 `prd.md`、`design.md`、前端规格及 shared 复用指南。 +- [x] 确认工作区没有用户现有改动,记录本任务实际改动文件。 + +### 2. Shared UI 组件 + +- [x] 新增 `shared/ui/select.tsx`,封装 Base UI Select 的触发器、列表、选项、滚动和状态样式。 +- [x] 新增 `shared/ui/popover.tsx`,封装 Base UI Popover 的 trigger、portal、positioner、popup 和关闭语义。 +- [x] 新增 `shared/ui/calendar.tsx`,基于 `react-day-picker` 和现有 Button/cn/token 提供单日选择基础能力。 +- [x] 新增 `shared/ui/date-picker.tsx`,组合 Popover + Calendar,支持受控 `Date | undefined`、禁用态、placeholder、label 语义和窄屏布局。 +- [x] 按实际依赖更新 `package.json` / `pnpm-lock.yaml`,只加入 Date Picker 所需的 `date-fns` / `react-day-picker`。 + +### 3. 选股页面接线 + +- [x] 将 `ExecutionToolbar` 的目标日期 Input 替换为 DatePicker,保持 `YYYY-MM-DD` 字符串边界和现有执行状态清理逻辑。 +- [x] 将策略原生 select 替换为 shared Select;将 `SelectionResultsWorkbench` 的分类原生 select 替换为 shared Select,保留 URL 更新和分页归一化。 +- [x] 从 `PageLayout` 移除 `metrics={resultMetrics}` 和页面级常驻 `ResultMetrics`。 +- [x] 将执行摘要指标放入 `ExecutionStatusDrawer`,并把执行状态按钮接到 `SelectionResultsWorkbench` 信号列表顶部;失败和无信号结果保留按需入口。 +- [x] 保留既有失败表格、空状态、主从信号选择、移动展开、分页、重执行确认和查询状态分支。 + +### 4. 测试与验证 + +- [x] 更新页面测试,覆盖 DatePicker 选中/清空日期、两个 Select 的可见值/回调、无常驻指标区、顶部状态按钮、抽屉摘要和焦点恢复。 +- [x] 通过页面行为覆盖 shared UI 的关键交互(Select 键盘/选择、DatePicker 弹层/选择),避免复制 shared primitive 实现逻辑新增重复测试。 +- [ ] 运行 `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build`;其中完整格式检查仍被未改动的既有 `DESIGN.md` 阻断,其余门禁通过。 +- [x] 使用浏览器 mock 验证桌面/移动视口、Select/DatePicker popup、抽屉、Escape/焦点恢复和页面级水平溢出。 +- [x] 执行 `trellis-check`,修复质量门禁或规格偏差后重新验证。 + +## Validation Commands + +在 `zhixing-web/` 下运行: + +```bash +pnpm format:check +pnpm lint +pnpm typecheck +pnpm test +pnpm build +``` + +## Verification Record + +- `pnpm lint`:通过。 +- `pnpm typecheck`:通过。 +- `pnpm test`:通过,4 个测试文件、26 项测试。 +- `pnpm build`:通过;Vite 仅提示现有单 chunk 超过 500 kB。 +- 变更文件 `prettier --check`:通过;完整 `pnpm format:check` 仅因未改动的 `zhixing-web/DESIGN.md` 失败。 +- `git diff --check`:通过。 +- 浏览器 mock:桌面与 390×844 移动视口均验证;Select 点击/键盘选择、DatePicker 弹层与中文无障碍 labels、抽屉 Escape/焦点恢复、页面级水平溢出均通过。 +- `trellis-check`:通过,并修复 DatePicker 清空后日期被服务器结果回填的边界问题。 + +## Risky Files and Rollback Points + +- `src/shared/ui/select.tsx`、`popover.tsx`、`calendar.tsx`、`date-picker.tsx`:新 shared primitive 和 Base UI/DayPicker API 兼容点;可独立回退并保留页面旧控件。 +- `package.json`、`pnpm-lock.yaml`:日期依赖变更;若安装/构建不兼容,回退依赖和四个 shared primitive。 +- `src/features/selection/pages/selection-results-page.tsx`:移除 metrics、接入日期选择器和抽屉状态;回退点不影响 API。 +- `src/features/selection/components/selection-results-workbench.tsx`:顶部筛选/状态入口和 Select 接线;回退时保留结果表、分页和详情面板。 +- `src/features/selection/components/execution-status-drawer.tsx`:执行摘要迁移;失败表格和 Dialog 生命周期应保持可回滚。 + +## Review Gates Before Start + +- [ ] `prd.md` 已通过需求收敛:目标、范围、验收标准明确且无阻塞问题。 +- [ ] `design.md` 已确认 shared/feature 边界、日期字符串转换、抽屉数据流和回滚点。 +- [ ] `implement.jsonl` 与 `check.jsonl` 已填入真实前端规格上下文。 +- [ ] 已向用户展示最终规划摘要,并获得对该摘要的明确实施批准。 diff --git a/.trellis/tasks/08-10-selection-results-filter-state-ui/prd.md b/.trellis/tasks/08-10-selection-results-filter-state-ui/prd.md new file mode 100644 index 0000000..3ae4753 --- /dev/null +++ b/.trellis/tasks/08-10-selection-results-filter-state-ui/prd.md @@ -0,0 +1,56 @@ +# 迭代选股结果页筛选与状态入口 + +## Goal + +让选股结果页把空间集中给筛选和信号结果:移除常驻的执行状态总览,将执行摘要改为信号列表顶部的按需入口,并通过右侧抽屉查看完整信息。同时把选股页使用的原生 `select` 和日期输入升级为项目 `shared/ui` 中基于 shadcn/ui Base UI 组合的可访问组件。 + +## Confirmed Facts + +- [SelectionResultsPage](zhixing-web/src/features/selection/pages/selection-results-page.tsx) 当前通过 `PageLayout.metrics` 渲染常驻的 `ResultMetrics`,展示目标股票数、实际参与数、命中股票数、信号条数、数据覆盖率和执行状态(约第 126–136、305–355 行)。 +- 当前目标交易日控件仍是 `Input type="date"`,策略控件和信号分类筛选仍是原生 ``。 +- `Select`、`DatePicker` 必须支持键盘操作、焦点可见、Escape/外部点击关闭、禁用态和窄屏不溢出;控件的业务选项和路由更新仍由 `selection` feature 持有。 +- 保留现有目标交易日、策略、执行/重执行、搜索词、分类筛选和分页行为;不引入全局 UI 状态。 + +### R2. 移除常驻执行状态总览 + +- 选股结果页不再通过 `PageLayout.metrics` 渲染常驻执行状态总览,主内容区域不再为这组指标预留高度。 +- 当存在可展示结果时,信号列表顶部提供一个明确的“执行状态”按钮,展示当前状态和未完成评估数量,并作为右侧抽屉的触发器。 +- 抽屉继续支持关闭按钮、遮罩、Escape 和关闭后焦点返回;移动端不能产生页面级水平溢出。 +- 执行摘要(目标股票数、实际参与数、命中股票数、信号条数、数据覆盖率、状态)和未完成评估表格迁移到抽屉内;无失败记录时保留可读空状态。 + +### R3. Shared UI 沉淀 + +- 在 `zhixing-web/src/shared/ui/` 新增可复用的 `Select`、`Popover`、`Calendar` 和 `DatePicker` 组合组件,底层复用现有 `@base-ui/react`、`Button`、`cn` 以及日期选择依赖。 +- shared UI 只负责通用交互、语义、样式和原生属性扩展,不读取 `selection` API、路由或业务类型。 +- 新组件遵循项目现有的具名导出、小写文件名、Tailwind token、`cn` 合并和严格 TypeScript 约束。 + +## Out of Scope + +- 不修改后端接口、数据库、策略计算、`SelectionResults` 字段含义或 query key。 +- 不改变 `/selection` URL 结构和现有搜索词、分类、分页参数的语义;目标交易日仍保持当前页面局部状态边界。 +- 不建设独立 npm/package 级 UI 库,不把 selection 业务组件迁移到 `shared/ui`,也不在本轮扩展全局表单校验或日期范围选择。 +- 不重做已有结果表、移动端信号卡片、详情面板和执行抽屉的业务数据逻辑。 + +## Acceptance Criteria + +- [x] `SelectionResultsPage` 不再给 `PageLayout.metrics` 传入常驻执行状态总览;桌面和移动页面均能把剩余空间用于结果内容。 +- [x] 目标交易日使用可访问的 `DatePicker`;选中日期后仍以 `YYYY-MM-DD` 触发当前结果查询/执行逻辑,清空和禁用行为不破坏既有状态机。 +- [x] 策略和信号分类使用 shared `Select`;键盘打开、上下选择、Enter 确认和 Escape 关闭均可用,分类改变仍更新既有 URL search 并重置分页到第 1 页。 +- [x] 有结果时,信号列表顶部可见执行状态按钮;点击后右侧抽屉展示摘要指标和未完成评估表格,关闭后焦点回到该按钮;失败和无信号结果也保留按需入口。 +- [x] 无失败记录时抽屉仍可打开并显示“暂无未完成评估”;主页面不再出现独立的常驻指标区或内联失败明细卡片。 +- [x] shared UI 组件不依赖 `features/selection`,没有直接业务分支;组件和页面测试覆盖关键可见交互。 +- [ ] `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test` 和 `pnpm build` 通过;lint/typecheck/test/build 与变更文件格式检查通过,完整格式检查仍被未改动的 `DESIGN.md` 阻断;浏览器验证已覆盖桌面/移动视口、Select、DatePicker、抽屉和页面级水平溢出。 + +## Open Questions + +无。按用户要求将原总览信息整体移入右侧抽屉,保留现有后端数据流和 URL 筛选边界。 diff --git a/.trellis/tasks/08-10-selection-results-filter-state-ui/task.json b/.trellis/tasks/08-10-selection-results-filter-state-ui/task.json new file mode 100644 index 0000000..e35ad83 --- /dev/null +++ b/.trellis/tasks/08-10-selection-results-filter-state-ui/task.json @@ -0,0 +1,26 @@ +{ + "id": "selection-results-filter-state-ui", + "name": "selection-results-filter-state-ui", + "title": "迭代选股结果页筛选与状态入口", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "yuxuanhui", + "assignee": "yuxuanhui", + "createdAt": "2026-08-10", + "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/package.json b/zhixing-web/package.json index b06bc41..ad63873 100644 --- a/zhixing-web/package.json +++ b/zhixing-web/package.json @@ -22,8 +22,10 @@ "@tanstack/react-router": "^1.170.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.4.0", "lucide-react": "^1.28.0", "react": "^19.2.8", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.8", "tailwind-merge": "^3.6.0", "zustand": "^5.0.14" diff --git a/zhixing-web/pnpm-lock.yaml b/zhixing-web/pnpm-lock.yaml index 4000933..8a62633 100644 --- a/zhixing-web/pnpm-lock.yaml +++ b/zhixing-web/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@base-ui/react': specifier: ^1.7.0 - version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.7.0(@date-fns/tz@1.5.0)(@types/react@19.2.18)(date-fns@4.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tanstack/react-query': specifier: ^5.101.4 version: 5.101.4(react@19.2.8) @@ -23,12 +23,18 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 lucide-react: specifier: ^1.28.0 version: 1.28.0(react@19.2.8) react: specifier: ^19.2.8 version: 19.2.8 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.18)(react@19.2.8) react-dom: specifier: ^19.2.8 version: 19.2.8(react@19.2.8) @@ -231,6 +237,9 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@emnapi/core@2.0.0-alpha.3': resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} @@ -816,6 +825,9 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1332,6 +1344,16 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -1784,7 +1806,7 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@base-ui/react@1.7.0(@date-fns/tz@1.5.0)(@types/react@19.2.18)(date-fns@4.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@base-ui/utils': 0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -1794,7 +1816,9 @@ snapshots: react-dom: 19.2.8(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: + '@date-fns/tz': 1.5.0 '@types/react': 19.2.18 + date-fns: 4.4.0 '@base-ui/utils@0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: @@ -1827,6 +1851,8 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@date-fns/tz@1.5.0': {} + '@emnapi/core@2.0.0-alpha.3': dependencies: '@emnapi/wasi-threads': 2.0.1 @@ -2370,6 +2396,8 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + date-fns@4.4.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2810,6 +2838,14 @@ snapshots: punycode@2.3.1: {} + react-day-picker@10.0.1(@types/react@19.2.18)(react@19.2.8): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.18 + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 diff --git a/zhixing-web/src/features/selection/components/execution-status-drawer.tsx b/zhixing-web/src/features/selection/components/execution-status-drawer.tsx index 545ec58..7ea0a6c 100644 --- a/zhixing-web/src/features/selection/components/execution-status-drawer.tsx +++ b/zhixing-web/src/features/selection/components/execution-status-drawer.tsx @@ -1,9 +1,8 @@ import { XIcon } from "lucide-react" import type { RefObject } from "react" -import type { SelectionResults } from "../api/selection.types" -import { IncompleteEvaluationTable } from "./incomplete-evaluation-table" -import { getSelectionRunStatusPresentation } from "./selection-presentation" +import { Badge } from "@/shared/ui/badge" +import { Button } from "@/shared/ui/button" import { Dialog, DialogClose, @@ -13,7 +12,10 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog" -import { Button } from "@/shared/ui/button" + +import type { SelectionResults } from "../api/selection.types" +import { IncompleteEvaluationTable } from "./incomplete-evaluation-table" +import { getSelectionRunStatusPresentation } from "./selection-presentation" interface ExecutionStatusDrawerProps { finalFocus: RefObject @@ -50,6 +52,37 @@ export function ExecutionStatusDrawer({
+
+

执行摘要

+
+ + + + + +
+
执行状态
+
+ {statusPresentation ? ( + + {statusLabel} + + ) : ( + {statusLabel} + )} +
+
+
+
@@ -73,3 +106,20 @@ export function ExecutionStatusDrawer({ ) } + +function SummaryMetric({ + label, + value, +}: { + label: string + value: number | string +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} diff --git a/zhixing-web/src/features/selection/components/execution-status-trigger.tsx b/zhixing-web/src/features/selection/components/execution-status-trigger.tsx new file mode 100644 index 0000000..0bc01c3 --- /dev/null +++ b/zhixing-web/src/features/selection/components/execution-status-trigger.tsx @@ -0,0 +1,49 @@ +import { CheckCircle2 } from "lucide-react" +import type { RefObject } from "react" + +import { Badge } from "@/shared/ui/badge" +import { Button } from "@/shared/ui/button" + +import type { SelectionResults } from "../api/selection.types" +import { getSelectionRunStatusPresentation } from "./selection-presentation" + +interface ExecutionStatusTriggerProps { + drawerOpen: boolean + onOpen: () => void + result: SelectionResults + triggerRef: RefObject +} + +export function ExecutionStatusTrigger({ + drawerOpen, + onOpen, + result, + triggerRef, +}: ExecutionStatusTriggerProps) { + const statusPresentation = getSelectionRunStatusPresentation(result.status) + + if (!statusPresentation) return null + + return ( + + ) +} 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 7a0a7a6..6947d31 100644 --- a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx +++ b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx @@ -1,10 +1,19 @@ -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useState, type RefObject } from "react" import { useNavigate, useSearch } from "@tanstack/react-router" import { Input } from "@/shared/ui/input" import { Pagination } from "@/shared/ui/pagination" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui/select" import type { SelectionResults, SelectionSignal } from "../api/selection.types" +import { ExecutionStatusTrigger } from "./execution-status-trigger" import { SignalDetailPanel } from "./signal-detail-panel" import { SignalRecordList } from "./signal-record-list" import { @@ -18,11 +27,17 @@ import { SignalTable } from "./signal-table" const PAGE_SIZE_OPTIONS = [5, 10, 15] as const interface SelectionResultsWorkbenchProps { + drawerOpen: boolean + onOpenExecutionStatus: () => void result: SelectionResults + triggerRef: RefObject } export function SelectionResultsWorkbench({ + drawerOpen, + onOpenExecutionStatus, result, + triggerRef, }: SelectionResultsWorkbenchProps) { const search = useSearch({ from: "/_workspace/selection" }) const navigate = useNavigate({ from: "/selection" }) @@ -53,7 +68,6 @@ export function SelectionResultsWorkbench({ const selectedSignal = currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ?? currentSignals[0] - useEffect(() => { if (page > pageCount) { void navigate({ @@ -92,32 +106,49 @@ export function SelectionResultsWorkbench({ return (
-
+
handleQueryChange(event.target.value)} placeholder="搜索股票名称或代码" type="search" value={query} /> - { + if (typeof value === "string") { + handleCategoryChange(value as SignalCategoryFilter) + } + }} value={category} > - {signalCategoryOptions.map((option) => ( - - ))} - - + + + + + + {signalCategoryOptions.map((option) => ( + + {option.label} + + ))} + + + + 筛选结果 {signalsTotal} 条 +
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 717837a..cc2b9d7 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 @@ -99,6 +99,7 @@ describe("SelectionResultsPage", () => { render() expect(screen.getByText("执行完成")).toBeInTheDocument() + expect(screen.queryByText("目标股票数")).not.toBeInTheDocument() const table = screen.getByRole("table") expect(table).toBeInTheDocument() expect(within(table).getByText("原始 B1")).toBeInTheDocument() @@ -130,11 +131,25 @@ describe("SelectionResultsPage", () => { const trigger = screen.getByRole("button", { name: "查看执行状态:部分成功,未完成评估 1 条", }) + expect(trigger).toHaveAttribute( + "aria-controls", + "selection-execution-status-drawer", + ) + expect(trigger).toHaveAttribute("aria-expanded", "false") fireEvent.click(trigger) + expect(trigger).toHaveAttribute("aria-expanded", "true") const dialog = await screen.findByRole("dialog", { name: "知行 B1 执行状态", }) + const summary = within(dialog).getByRole("region", { name: "执行摘要" }) + expect(within(summary).getByText("目标股票数")).toBeInTheDocument() + expect(within(summary).getByText("实际参与数")).toBeInTheDocument() + expect(within(summary).getByText("命中股票数")).toBeInTheDocument() + expect(within(summary).getByText("信号条数")).toBeInTheDocument() + expect(within(summary).getByText("数据覆盖率")).toBeInTheDocument() + expect(within(summary).getByText("100.00%")).toBeInTheDocument() + expect(within(summary).getByText("部分成功")).toBeInTheDocument() const table = within(dialog).getByRole("table") expect( within(table).getByRole("columnheader", { name: "股票名称" }), @@ -153,6 +168,7 @@ describe("SelectionResultsPage", () => { await waitFor(() => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument() }) + expect(trigger).toHaveAttribute("aria-expanded", "false") expect(document.activeElement).toBe(trigger) }) @@ -175,6 +191,111 @@ describe("SelectionResultsPage", () => { await waitFor(() => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument() }) + expect( + screen.getByRole("button", { + name: "查看执行状态:执行完成,未完成评估 0 条", + }), + ).toHaveFocus() + }) + + it("keeps execution status accessible when no signals are hit", async () => { + useSelectionResults.mockReturnValue({ + data: { + ...selectedResult, + signal_count: 0, + signals: [], + signals_total: 0, + }, + isError: false, + isPending: false, + }) + + render() + + const trigger = screen.getByRole("button", { + name: "查看执行状态:执行完成,未完成评估 0 条", + }) + fireEvent.click(trigger) + + const dialog = await screen.findByRole("dialog", { + name: "知行 B1 执行状态", + }) + expect(within(dialog).getByText("信号条数")).toBeInTheDocument() + }) + + it("keeps execution status accessible for a failed result", async () => { + useSelectionResults.mockReturnValue({ + data: { + ...selectedResult, + failed_count: 2, + failures: [ + { + name: "招商银行", + reason: "目标日数据读取失败", + status: "data_error", + ts_code: "600036.SZ", + }, + { + name: "宁德时代", + reason: "指标计算失败", + status: "calculation_error", + ts_code: "300750.SZ", + }, + ], + signal_count: 0, + signals: [], + status: "failed", + }, + isError: false, + isPending: false, + }) + + render() + + fireEvent.click( + screen.getByRole("button", { + name: "查看执行状态:执行失败,未完成评估 2 条", + }), + ) + const dialog = await screen.findByRole("dialog", { + name: "知行 B1 执行状态", + }) + expect(within(dialog).getByText("执行失败")).toBeInTheDocument() + }) + + it("uses shared selects and resets pagination when the category changes", async () => { + render() + + expect(screen.getByRole("combobox", { name: "策略" })).toHaveTextContent( + "知行 B1", + ) + const categoryTrigger = screen.getByRole("combobox", { + name: "筛选信号分类", + }) + expect(categoryTrigger).toHaveTextContent("全部信号") + + fireEvent.click(categoryTrigger) + const oversoldOption = await screen.findByRole("option", { name: "超卖类" }) + fireEvent.pointerDown(oversoldOption, { pointerType: "mouse" }) + fireEvent.click(oversoldOption) + + expect(routerNavigate).toHaveBeenCalled() + const lastCall = + routerNavigate.mock.calls[routerNavigate.mock.calls.length - 1] + const searchUpdate = lastCall[0].search as (previous: { + category: string + page: number + pageSize: number + search: string + }) => Record + expect( + searchUpdate({ category: "all", page: 4, pageSize: 5, search: "" }), + ).toEqual({ + category: "oversold", + page: 1, + pageSize: 5, + search: "", + }) }) it("filters signals, updates the detail panel, and expands mobile details", () => { @@ -298,14 +419,14 @@ describe("SelectionResultsPage", () => { ).toBeInTheDocument() }) - it("renders a no-data state and triggers an initial run after date selection", () => { + it("renders a no-data state and triggers an initial run after date selection", async () => { useSelectionResults.mockReturnValue({ data: { ...selectedResult, run_id: null, signals: [], status: "no_data", - target_trade_date: null, + target_trade_date: "2026-08-08", }, isError: false, isPending: false, @@ -314,9 +435,12 @@ describe("SelectionResultsPage", () => { render() expect(screen.getByText("暂无策略结果")).toBeInTheDocument() - fireEvent.change(screen.getByLabelText("目标交易日"), { - target: { value: "2026-08-08" }, + fireEvent.click(screen.getByRole("button", { name: "目标交易日" })) + const calendar = await screen.findByRole("grid") + const dateButton = within(calendar).getByRole("button", { + name: "2026年8月9日 星期日", }) + fireEvent.click(dateButton) fireEvent.click(screen.getByRole("button", { name: "执行策略" })) expect(mutate).toHaveBeenCalledWith( @@ -324,13 +448,28 @@ describe("SelectionResultsPage", () => { request: { rerun: false, strategy: "zhixing_b1", - target_trade_date: "2026-08-08", + target_trade_date: "2026-08-09", }, }, expect.any(Object), ) }) + it("keeps the target trade date empty after clearing the date picker", async () => { + render() + + const dateTrigger = screen.getByRole("button", { name: "目标交易日" }) + fireEvent.click(dateTrigger) + const calendar = await screen.findByRole("grid") + fireEvent.click( + within(calendar).getByRole("button", { + name: "2026年8月8日 星期六,已选择", + }), + ) + + expect(dateTrigger).toHaveTextContent("选择日期") + }) + it("continues polling a persisted running run after page reload", () => { useSelectionResults.mockReturnValue({ data: { ...selectedResult, signals: [], status: "running" }, diff --git a/zhixing-web/src/features/selection/pages/selection-results-page.tsx b/zhixing-web/src/features/selection/pages/selection-results-page.tsx index e992ab9..4004515 100644 --- a/zhixing-web/src/features/selection/pages/selection-results-page.tsx +++ b/zhixing-web/src/features/selection/pages/selection-results-page.tsx @@ -1,10 +1,4 @@ -import { - AlertTriangle, - CheckCircle2, - Play, - RefreshCw, - Search, -} from "lucide-react" +import { AlertTriangle, Play, RefreshCw, Search } from "lucide-react" import { useSearch } from "@tanstack/react-router" import { useRef, useState, type RefObject } from "react" @@ -18,7 +12,6 @@ import type { SelectionResults, SelectionResultsQuery, } from "@/features/selection/api/selection.types" -import { Badge } from "@/shared/ui/badge" import { Button } from "@/shared/ui/button" import { Card, @@ -35,17 +28,28 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog" -import { Input } from "@/shared/ui/input" +import { DatePicker } from "@/shared/ui/date-picker" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui/select" import { Skeleton } from "@/shared/ui/skeleton" import { ExecutionStatusDrawer } from "../components/execution-status-drawer" -import { getSelectionRunStatusPresentation } from "../components/selection-presentation" +import { ExecutionStatusTrigger } from "../components/execution-status-trigger" import { SelectionResultsWorkbench } from "../components/selection-results-workbench" const STRATEGY = "zhixing_b1" as const export function SelectionResultsPage() { - const [targetTradeDate, setTargetTradeDate] = useState(null) + // undefined follows the persisted result; null means the user cleared the picker. + const [targetTradeDate, setTargetTradeDate] = useState< + string | null | undefined + >(undefined) const [activeRunId, setActiveRunId] = useState(null) const [executionStatusDrawerOpen, setExecutionStatusDrawerOpen] = useState(false) @@ -71,7 +75,9 @@ export function SelectionResultsPage() { const trigger = useTriggerSelectionRun() const selectedTargetTradeDate = - targetTradeDate ?? results.data?.target_trade_date ?? "" + targetTradeDate === undefined + ? (results.data?.target_trade_date ?? "") + : (targetTradeDate ?? "") const selectedDateResult = targetTradeDate && @@ -116,25 +122,13 @@ export function SelectionResultsPage() { ) } - function handleDateChange(value: string) { - setTargetTradeDate(value) + function handleDateChange(value: Date | undefined) { + setTargetTradeDate(value ? formatLocalTradeDate(value) : null) setExecutionStatusDrawerOpen(false) setActiveRunId(null) trigger.reset() } - const resultMetrics = - displayedResult && - displayedResult.status !== "no_data" && - displayedResult.status !== "running" ? ( - setExecutionStatusDrawerOpen(true)} - result={displayedResult} - triggerRef={executionStatusTriggerRef} - /> - ) : null - return ( <> } - metrics={resultMetrics} mode="bounded-workspace" >
@@ -169,14 +162,24 @@ export function SelectionResultsPage() { {!isRunning && !hasQueryError && displayedResult?.status === "failed" ? ( - + setExecutionStatusDrawerOpen(true)} + result={displayedResult} + triggerRef={executionStatusTriggerRef} + /> ) : null} {!isRunning && !hasQueryError && displayedResult && displayedResult.status !== "no_data" && displayedResult.status !== "failed" ? ( - + setExecutionStatusDrawerOpen(true)} + result={displayedResult} + triggerRef={executionStatusTriggerRef} + /> ) : null}
@@ -227,7 +230,7 @@ interface ExecutionToolbarProps { hasExistingResult: boolean isFailedResult: boolean isRunning: boolean - onDateChange: (value: string) => void + onDateChange: (value: Date | undefined) => void onExecute: () => void selectedTargetTradeDate: string triggerError: boolean @@ -245,33 +248,35 @@ function ExecutionToolbar({ return (
-
- ) : null} -
- ) +function parseLocalTradeDate(value: string): Date | undefined { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) + if (!match) return undefined + + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const date = new Date(year, month - 1, day) + + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return undefined + } + return date +} + +function formatLocalTradeDate(value: Date): string { + const month = String(value.getMonth() + 1).padStart(2, "0") + const day = String(value.getDate()).padStart(2, "0") + return `${value.getFullYear()}-${month}-${day}` } function LoadingState() { @@ -423,7 +408,12 @@ function NoDataState() { ) } -function FailureState({ result }: { result: SelectionResults }) { +function FailureState({ + drawerOpen, + onOpenExecutionStatus, + result, + triggerRef, +}: ResultStateProps) { return ( @@ -440,49 +430,76 @@ function FailureState({ result }: { result: SelectionResults }) { -

- 目标交易日: - - {result.target_trade_date} - - ,失败股票: - - {result.failed_count} - -

+
+

+ 目标交易日: + + {result.target_trade_date} + + ,失败股票: + + {result.failed_count} + +

+ +
) } -function ResultState({ result }: { result: SelectionResults }) { +function ResultState({ + drawerOpen, + onOpenExecutionStatus, + result, + triggerRef, +}: ResultStateProps) { return result.signal_count === 0 ? ( - + ) : ( - + ) } -function NoSignalState() { +function NoSignalState({ + drawerOpen, + onOpenExecutionStatus, + result, + triggerRef, +}: ResultStateProps) { return ( - 本次没有命中信号 - - 目标交易日数据完整,但没有符合公式条件的股票。 - +
+
+ 本次没有命中信号 + + 目标交易日数据完整,但没有符合公式条件的股票。 + +
+ +
) } - -function Metric({ label, value }: { label: string; value: number | string }) { - return ( -
-

{label}

-

- {value} -

-
- ) -} diff --git a/zhixing-web/src/shared/ui/calendar.tsx b/zhixing-web/src/shared/ui/calendar.tsx new file mode 100644 index 0000000..f8a775e --- /dev/null +++ b/zhixing-web/src/shared/ui/calendar.tsx @@ -0,0 +1,71 @@ +import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from "lucide-react" +import { DayPicker, type DayPickerProps } from "react-day-picker" +import { zhCN } from "react-day-picker/locale" + +import { cn } from "@/shared/ui/utils" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: DayPickerProps) { + return ( + { + const Icon = + orientation === "left" + ? ChevronLeft + : orientation === "right" + ? ChevronRight + : orientation === "up" + ? ChevronUp + : ChevronDown + return ( +