Develop #20

Merged
sakibcc merged 19 commits from develop into main 2026-08-31 17:16:06 +08:00
24 changed files with 803 additions and 66 deletions
Showing only changes of commit 3636d67004 - Show all commits
@@ -0,0 +1,7 @@
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"执行 format、lint、typecheck、Vitest 和 build,并按用户可见行为检查新增状态。"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"检查应用壳、表格、分页、Dialog、移动记录卡的语义结构、focus、键盘和触控命中区域。"}
{"file":".trellis/spec/frontend/state-management.md","reason":"检查分页 URL 状态、筛选/选中/展开局部状态和 React Query 服务器状态没有越界到 Zustand。"}
{"file":".trellis/spec/frontend/directory-structure.md","reason":"检查 app/layout、feature selection/home、shared/ui 的依赖方向和 HomeShell 清理结果。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"确认本次只改变前端呈现,不破坏既有后端 HTTP、selection 类型、query 和状态契约。"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"检查新增 layout/pagination 是否是真实复用模块,避免把 selection 业务字段抽入 shared。"}
{"file":".trellis/tasks/archive/2026-08/08-09-responsive-frontend-design/prd.md","reason":"对照已确认的响应式验收、A+C 选型、无页面标题模块和移动端导航约束。"}
@@ -0,0 +1,139 @@
# 原型驱动的响应式前端生产迁移设计
## 1. 设计边界
本任务把已选定的 A+C 原型迁移到现有 React 应用,涉及应用级 layout、`/` 首页、`/selection` 选股结果页和 shared pagination。后端 API、领域计算、数据库和未实现的“行情数据/同步任务”页面保持不变。
原型只作为视觉和交互决策来源,不直接复制 HTML/CSS/JS。生产实现继续遵循 feature 垂直切片和现有 `shared/ui` primitives,确保服务器状态仍由 TanStack Query 管理。
## 2. 目标模块边界
```text
src/
├── app/
│ └── layout/
│ ├── app-layout.tsx # 品牌、响应式导航、顶栏、Outlet、滚动边界
│ ├── navigation.ts # 应用级导航与路由上下文模型
│ └── page-layout.tsx # document/bounded-workspace 内容布局
├── routes/
│ └── route-tree.tsx # root → workspace pathless route → 页面路由
├── features/
│ ├── home/
│ │ └── pages/home-page.tsx # 市场概览业务状态
│ └── selection/
│ ├── components/
│ │ ├── selection-results-workbench.tsx
│ │ ├── signal-table.tsx
│ │ ├── signal-record-list.tsx
│ │ └── signal-detail-panel.tsx
│ └── pages/selection-results-page.tsx
└── shared/ui/
└── pagination.tsx # 无业务语义的分页 primitive
```
`HomeShell` 在两个业务页面迁移后删除,避免生产中长期存在两套应用壳。`SystemStatusPage` 当前不在路由树中,不为了本任务引入第三套页面布局。
## 3. Layout seam 与路由集成
### 3.1 Pathless workspace route
`route-tree.tsx` 把当前根路由改为:root route → `workspaceRoute`(只有 `id`,没有 URL path)→ `/` 和 `/selection`。`workspaceRoute` 使用 `AppLayout`,页面只负责业务内容,不再导入 `HomeShell` 或传 `activeSection`。
导航激活状态使用 TanStack Router 的 `Link`/active 状态和真实 route path 推导;导航模型只保存 `id`、`label`、`to`、图标和 availability,不读取 feature 数据。顶栏上下文由同一份 route presentation 映射集中提供:`/` 显示市场数据概览,`/selection` 显示知行 B1 执行结果。
### 3.2 AppLayout interface
`AppLayout` 对页面的外部接口不暴露布局参数,只渲染 `<Outlet />`。内部统一负责:
- 桌面 ≥1024px 的 232px 侧栏、品牌、主导航、低频入口和静态研究空间说明;
- 平板 768–1023px 的图标 rail,保留可访问名称;
- 移动 <768px 的紧凑顶栏、四项底部导航和“更多” sheet;
- 搜索和头像作为现有产品占位入口保留,不新增搜索业务;
- `min-height: 0`、`min-width: 0`、`svh` 和安全区 padding,确保 bounded 页面只有业务数据区滚动;
- 桌面/触控密度由 CSS breakpoint 控制,React render 不读取 `window.innerWidth`。
未实现的导航项继续使用禁用状态;移动端低频入口在 sheet 中以禁用或说明状态呈现,不创建虚假的路由。
### 3.3 PageLayout interface
```tsx
interface PageLayoutProps {
mode?: "document" | "bounded-workspace"
actions?: React.ReactNode
metrics?: React.ReactNode
children: React.ReactNode
}
```
`PageLayout` 不渲染页面大标题、眉题或说明。`document` 模式用于首页等自然页面;`bounded-workspace` 模式用于选股主从数据区,形成 `AppLayout → main → PageLayout → data panel` 的连续高度链。页面只提供操作区、指标区和业务内容,布局内部处理 gutter、移动底部安全区、桌面剩余高度和断点堆叠。
## 4. Visual tokens 与 shared primitive 策略
在 `globals.css` 将原型 token 收敛为生产主题:暖白 canvas `#f5f1ec`、白色 surface、炭黑文字 `#111111`、灰色 hairline 和语义 success/warning/error。桌面工作台使用紧凑字号,不引入 `DESIGN.md` 中营销页的 display-xl/大面积留白。
现有 `Button`、`Input`、`Badge`、`Card`、`Dialog` 优先通过新增有限 variant 或组合 class 适配:
- 桌面控件保持约 32px 密度;移动交互控件通过响应式 class 至少达到 44px 命中区域;
- 默认卡片和表格使用 hairline 层级,避免用常规阴影表达数据层级;
- 状态颜色不只依赖颜色,保留文字和 aria 语义;
- 只在确有跨 feature 复用时新增 `shared/ui/pagination.tsx`,不把 selection 字段或业务格式化放入 shared。
## 5. Selection 数据流与视图模型
```text
useSelectionResults / useSelectionRun
│
▼
SelectionResults view model
│
filters → current page slice → selected signal
├───────────────┬────────────────┐
▼ ▼ ▼
desktop table mobile records detail panel
```
- API 类型、查询和轮询逻辑保持在 `features/selection/api/`,不改 HTTP 契约。
- 页面先对 `signals` 做名称/代码搜索和 category 筛选,再做客户端分页;过滤或 page size 改变时回到第 1 页。
- `page` 与 `pageSize` 使用 selection route 的 validated search params,使分页链接可复现;搜索词、分类、详情选中和移动展开属于页面局部状态,不写 Zustand。
- Desktop table 和 mobile record list 消费同一份当前页 slice。桌面列显示股票/代码、子信号、收盘价、关键详情;移动卡片首行显示股票/代码与价格,第二行显示 badge,详情默认折叠。
- 详情面板以稳定 key `${ts_code}-${category}` 选中信号,显示股票、代码、子信号、收盘价、目标交易日、策略和 `details` 格式化结果。原型中没有后端契约支撑的置信度、数据状态和研究详情 CTA 不进入生产。
- 当过滤、分页或异步结果导致当前选中项不在可见列表时,自动选择当前页第一条;无结果时详情面板呈现明确空状态。
- 现有执行确认、运行中轮询、失败和查询错误由页面保留;状态反馈就地出现在操作/数据区域,不额外插入原型评审用的状态样例区。
### Pagination primitive
```tsx
interface PaginationProps {
page: number
pageSize: number
total: number
pageSizeOptions?: readonly number[]
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
}
```
primitive 只负责范围文案、上一页/下一页、页码、每页数量、disabled 和 aria;selection feature 负责切片、筛选和导航 search params。
## 6. Responsive 与滚动契约
| 视口 | 应用壳 | 选股结果 | 滚动和触控 |
| --- | --- | --- | --- |
| ≥1024px | 232px 侧栏 + 顶栏 | 主列表/详情双栏 | 应用壳 bounded;仅列表数据行滚动 |
| 768–1023px | 64px 图标 rail + 顶栏 | 主从区域压缩,必要时详情随列布局调整 | 保持紧凑桌面逻辑,图标有可访问名称 |
| <768px | 紧凑顶栏 + 4 项底部导航 | 单列记录卡,详情折叠 | 自然页面滚动;内容预留底部安全区,控件 ≥44px |
桌面 bounded chain 使用 `h-svh`、`overflow-hidden` 和 `min-h-0`,不使用 `calc(100vh - 固定像素)`。移动端通过 breakpoint 解除外层 bounded,恢复页面自然滚动,避免表格、分页和底部导航形成嵌套滚动。
## 7. Compatibility、迁移和回滚
- 保持 `/`、`/selection` URL 不变;query key、API 类型、执行确认和轮询行为不变。
- 迁移顺序为 layout/route tree → shared tokens/primitives → HomePage → SelectionResultsPage/pagination → 删除 `HomeShell` → tests/quality gate。
- 每一步都保留可回滚点:路由树可以恢复 root 直接挂载页面;selection pagination 可以退回完整 signals 表;视觉 token 仅影响前端 bundle,不涉及后端数据。
- 本任务不提交、不推送、不发布;所有改动限定在当前仓库。
## 8. Risks and deferred items
- 当前 API 返回完整信号列表,客户端分页只改善浏览,不解决传输量;服务端分页需后续基于真实数据规模重新设计契约。
- 现有 shared primitives 的主题变量和默认阴影与原型不同,需通过实际页面测试确认无暗色主题回归;不为了本任务删除已有主题能力。
- 真实浏览器视觉检查依赖可访问的本地 HTTP 开发地址;本轮最低质量证据以测试、构建和可审查 DOM/CSS 为准,若环境允许再补常见视口截图。
@@ -0,0 +1,7 @@
{"file":".trellis/spec/frontend/index.md","reason":"实现前确认 React feature 垂直切片、应用壳边界、同源 API 和质量入口。"}
{"file":".trellis/spec/frontend/directory-structure.md","reason":"将共享 layout、shared pagination、home/selection 业务组件放在正确边界,避免 shared 反向依赖 feature。"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"约束语义 HTML、键盘可用性、aria、现有 Button/Card/Dialog 组合和 responsive 组件写法。"}
{"file":".trellis/spec/frontend/state-management.md","reason":"确认 route search、TanStack Query、页面局部交互与 Zustand 的状态归属。"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"保持严格 TypeScript、API 类型边界和 route search 的可验证类型。"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"在新增 PageLayout、Pagination 和 token helper 前验证真实复用价值与边界。"}
{"file":".trellis/tasks/archive/2026-08/08-09-responsive-frontend-design/design.md","reason":"承接已确认的 A+C 方向、layout seam、响应式断点、分页接口和迁移顺序。"}
@@ -0,0 +1,80 @@
# 原型驱动的响应式前端生产迁移执行计划
## Scope
在不改变后端契约和业务计算的前提下,完成共享应用壳、首页、选股结果页和客户端分页的生产迁移。实现前必须通过本任务最终规划摘要审批并执行 `task.py start`;本文件本轮只作为执行计划。
## Ordered checklist
### 1. 预检与布局迁移
- [x] 执行 `trellis-before-dev`,重新读取 frontend spec 与相关 cross-layer/code-reuse 指南。
- [x] 检查工作区,确认只包含本任务已创建的规划文件,不覆盖其他用户变更。
- [x] 新增 `src/app/layout/navigation.ts`、`app-layout.tsx`、`page-layout.tsx`,定义导航模型、route context、桌面/平板/移动布局和滚动边界。
- [x] 将 `routes/route-tree.tsx` 改为 root → pathless workspace → `/`、`/selection`,保持 URL 不变。
- [x] 删除页面对 `HomeShell` 和 `activeSection` 的依赖,确认布局只通过 `<Outlet />` 组合页面。
### 2. 视觉 token 与 shared UI
- [x] 按原型更新 `src/styles/globals.css` 的 canvas/surface/ink/hairline/semantic tokens 和基础排版。
- [x] 只在需要时调整 Button/Input/Badge/Card/Dialog 的有限 variant 或响应式命中尺寸,保持现有 API 和暗色主题可编译。
- [x] 新增 `src/shared/ui/pagination.tsx`,仅承载通用分页语义、范围文案、按钮禁用和 aria。
### 3. 首页迁移
- [x] 用 `PageLayout` 重组 `HomePage`,移除内容区重复标题说明,保留 overview query、加载/错误/无数据、状态标签和失败股票 Dialog。
- [x] 让首页在 document 模式下自然滚动,验证桌面/移动导航和搜索/头像占位不回归。
- [x] 更新 `home-page.test.tsx`,增加布局入口、页面上下文和关键状态的用户可见断言;保留原有失败弹窗行为断言。
### 4. 选股结果页迁移
- [x] 将执行条件/操作折叠为紧凑 action row,移除大标题、眉题和说明模块,保留日期、策略、执行、重跑确认和轮询状态。
- [x] 把结果指标转换为原型的紧凑 metrics;保留 coverage、目标/参与/命中/信号计数和状态语义。
- [x] 在 selection route 增加受控的 `page`/`pageSize` search params 解析和导航更新;不把结果数据写入 Zustand。
- [x] 实现 feature-owned 筛选 view model、`SignalTable`、移动 `SignalRecordList` 和 `SignalDetailPanel`,三者消费同一当前页数据。
- [x] 支持名称/代码搜索、category 筛选、分页、每页数量、选中行、移动详情展开、无匹配和无信号状态;不添加原型中的虚假详情字段或无后端 CTA。
- [x] 保留多子信号分行、重跑确认、运行中轮询、查询错误、执行失败和失败列表行为。
- [x] 更新 `selection-results-page.test.tsx`,覆盖分页/筛选/选中详情/移动记录展开及原有执行状态回归。
### 5. 收口与清理
- [x] 删除不再使用的 `features/home/components/home-shell.tsx` 及其 import;确认没有残留 `activeSection` 或旧布局引用。
- [x] 检查 `rg` 结果,确认没有页面直接 `fetch`、没有新增业务代码进入 `shared/ui`、没有 `window.innerWidth` 响应式分支。
- [x] 检查键盘 focus、Escape、Dialog focus、禁用按钮、aria label 和移动底部安全区。
## Validation commands
在 `zhixing-web/` 下按顺序运行:
```bash
pnpm format:check
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
针对任务的只读检查:
```bash
rg -n "activeSection|HomeShell|window\.innerWidth|fetch\(" src
rg -n "min-w-\[720px\]|overflow-x-auto" src/features/selection src/app
```
如本地浏览器环境可访问 HTTP 开发服务器,再验证 1440×900、1280×800、768×1024、390×844、375×812:导航切换、分页、筛选、详情选中/展开、Dialog、无水平溢出和底部安全区。
本轮验证记录:任务文件独立 Prettier 检查、`pnpm lint`、`pnpm typecheck`、`pnpm test`(18/18)和 `pnpm build` 均通过;全量 `pnpm format:check` 仍被仓库原有未修改的 `DESIGN.md` 格式问题阻断。HTTP 冒烟检查已确认 `/` 与 `/selection` 应用壳和路由可加载,但本地后端未启动,结果成功态和真实信号数据未在浏览器中验证。
## Risky files and rollback points
- `src/routes/route-tree.tsx`:路由父子关系改变,若失败可恢复 root 直接挂载两个页面。
- `src/app/layout/*`:应用壳和滚动边界;若视觉回归可先恢复页面外层 `HomeShell`,不涉及 API。
- `src/features/selection/pages/selection-results-page.tsx` 及新增 selection components:业务呈现变化;可退回旧结果卡片和完整表格,不改 query/API。
- `src/styles/globals.css` 与 shared primitives:主题/密度全局影响;保留每步检查结果,必要时按 variant 回退局部 class。
- 删除 `home-shell.tsx` 只在全量引用检查通过后执行。
## Review gates before start
- [x] `prd.md`、`design.md` 和本文件已完成收敛,开放问题为空。
- [x] `implement.jsonl` 与 `check.jsonl` 不再包含模板占位行,且只列入真实 spec/research 上下文。
- [x] 用户已审批最终规划摘要;未审批前不得执行 `task.py start`、不得修改生产代码。
@@ -0,0 +1,87 @@
# 参考原型调整前端页面
## Goal
将已确认的 A+C 响应式工作台原型落地到生产 React 页面:桌面端提供紧凑的研究工作台和主从结果浏览,移动端提供可触控的单列内容与底部导航,同时保留现有选股执行、查询和状态语义。
## Background and confirmed facts
- 原型文件为 `zhixing-web/prototypes/responsive-workbench.html`,顶部明确标注为 throwaway prototype;默认方案是 A「紧凑工作台」应用壳 + C「主从研究台」详情面板,A/B 仅用于对比。
- 已确认的产品微调是:删除各业务页面内容区内重复的标题、眉题和说明模块;内容区直接从操作按钮、指标和业务数据开始,页面上下文由应用顶栏承担。
- 原型已验证桌面端的固定侧栏、顶部工具区、紧凑指标区、筛选/分页/表格和持久化选中详情面板;移动端切换为记录卡、折叠详情、底部四项主导航和“更多”菜单。
- 现有生产页面位于 `zhixing-web/src/features/`:`HomePage` 展示市场数据概览,`SelectionResultsPage` 展示知行 B1 执行条件、状态、指标和信号表;两者都依赖 `features/home/components/home-shell.tsx`。
- 当前路由只有 `/` 和 `/selection`;`HomeShell` 由页面手工传入 `activeSection`,且窄屏仍保留图标侧栏,没有移动底部导航。
- `SelectionResultsPage` 当前信号表使用 `min-width: 720px` 和横向滚动,没有客户端分页,也没有选中信号详情面板;后端已返回完整 `signals` 列表和稳定字段,可在前端复用当前数据做首版分页。
- 已归档的响应式前端设计任务规定生产迁移顺序为“pathless layout → 首页 → selection 页面 → shared pagination”,并要求生产实现重新编写、不能直接复制原型代码。
- 前端约束是 React 19、TypeScript、TanStack Router、TanStack Query、Tailwind CSS 4、Base UI 和现有 `shared/ui` primitives;业务逻辑留在 feature,跨 feature 的 layout/pagination 才进入 `app`/`shared`。
## Requirements
### R1. 共享响应式应用壳
- 将应用级品牌、桌面/平板/移动导航、顶栏搜索占位、用户入口、内容容器和滚动策略集中到应用级 layout。
- 使用 TanStack Router 的 pathless layout route 共享应用壳;页面不再手工传入 `activeSection`。
- 桌面端保持紧凑侧栏和顶栏;移动端使用四项底部主导航,“行情数据”“同步任务”等未实现入口保持不可用或进入明确的更多入口,不伪装为已实现功能。
- 应用壳集中处理移动安全区、触控命中尺寸和内容底部留白;不在各页面散落设备宽度判断。
### R2. 首页与选股页迁移
- 首页保留当前市场数据概览 API 和所有加载、错误、无数据、更新状态、失败股票弹窗行为,但采用原型的视觉密度和内容起始结构。
- 选股页保留目标交易日选择、首次执行、重跑确认、轮询执行中、查询错误、无数据、失败、部分成功/成功和多子信号展示行为。
- 选股页按 A+C 方案在桌面端呈现紧凑操作/指标区、命中信号主列表和选中信号详情;同一股票的多个子信号必须继续分别可见。
- 业务内容区不重复渲染大标题、眉题和说明模块;操作和核心数据直接进入页面主体。
### R3. 信号筛选、分页与移动呈现
- 选股信号支持按股票名称/代码搜索、按信号类别筛选、切换每页数量和页码,并显示总数及当前范围。
- 桌面端使用高密度语义表格;筛选栏、表头和分页保持可见,只有数据行区域滚动。
- 移动端使用同一批数据的记录卡,优先展示股票、代码、收盘价和子信号,关键详情可展开;不得仅依赖整页横向滚动。
- 分页、选中行、展开详情、弹窗和更多菜单都支持键盘、触控和必要的 aria 语义。
- 首版分页使用当前已加载 `signals` 的客户端切片;服务端分页不在本任务内引入新的 HTTP 契约。
### R4. 生产质量与兼容性
- 优先复用现有 `Button`、`Badge`、`Card`、`Dialog`、`Input` 等 primitives;只有跨 feature 且无业务语义的能力才抽到 `shared`。
- 不修改后端业务计算、同步流程或选股结果 HTTP 契约;不把 API 数据复制到 Zustand。
- 通过前端格式、lint、typecheck、Vitest 和 build;页面测试以用户可见行为覆盖新增响应式壳、分页、筛选、选中详情和现有状态行为。
## Acceptance Criteria
- [ ] `/` 和 `/selection` 共用同一生产应用壳;路由切换时激活导航由路由状态推导,不再依赖页面传入 `activeSection`。
- [ ] 桌面端在常见 1280px/1440px 宽度下呈现暖白画布、紧凑侧栏/顶栏、指标区和 A+C 主从研究布局,无原型之外的大面积空白。
- [ ] 选股页内容区没有重复的大标题、眉题或说明模块,内容顺序从操作/指标/业务数据开始。
- [ ] 选股页保留执行确认、执行中、查询错误、无数据、失败、部分成功/成功和多子信号可见行为。
- [ ] 信号列表可搜索、筛选、翻页和切换每页数量,并显示总数、当前范围、当前页和禁用状态。
- [ ] 桌面端长列表仅在数据区滚动,筛选栏、表头和分页保持可见;页面不出现竞争性的嵌套纵向滚动。
- [ ] 移动端在 375px/390px 宽度下无页面级水平溢出,内容不被底部导航或安全区遮挡,主要控件命中区域适合触控。
- [ ] 移动端信号以记录卡呈现,详情可展开,底部四项主导航可达,低频功能通过“更多”菜单承载。
- [ ] 首页原有市场数据状态、失败股票弹窗和路由入口行为不回归。
- [ ] `zhixing-web/` 下 `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build` 均通过。
## Out of Scope
- 不修改后端 HTTP、数据库、市场数据同步或知行 B1 公式语义。
- 不新增行情数据和同步任务业务页面;未实现入口继续明确不可用。
- 不实现暗色模式、服务端分页、表格排序/列配置、虚拟滚动、实时行情或交易能力。
- 不将 throwaway 原型 HTML 直接作为生产组件或导入生产 bundle。
- 不提交、推送或发布外部系统变更。
## Key Decisions
- 用户已确认本任务按“共享应用壳 + 首页迁移 + 选股页迁移 + 客户端分页”作为第一阶段完整范围。
- 采用已确认的 A+C 组合;A/B 只保留为原型对比参考,不进入生产路由。
- 内容区移除重复的页面标题、眉题和说明模块;页面上下文由应用顶栏承担。
- 首版分页只切分当前已加载的 `signals`,不扩展后端分页契约。
## Risks and Deferred Items
- 现有接口返回完整 signals 列表,客户端分页不降低网络传输量;服务端分页、排序和虚拟滚动留待有真实规模证据后单独设计。
- 原型详情面板中的置信度、数据状态和“打开研究详情”等 mock 内容不在当前 API 契约内;生产详情只展示已有稳定字段,不虚构业务数据或无效入口。
- 原型文件仍是评审材料,生产页面必须使用 React、现有 UI primitives 和测试重新实现。
## Artifact Status
- `prd.md`:需求与验收已收敛,无阻塞性开放问题。
- `design.md`:已补齐生产架构、数据流、响应式边界、兼容性和回滚策略。
- `implement.md`:已补齐执行顺序、验证命令、风险文件和启动前 review gate。
- `implement.jsonl` / `check.jsonl`:已替换为真实的 spec/research 上下文条目。
@@ -0,0 +1,26 @@
{
"id": "prototype-driven-frontend-adjustments",
"name": "prototype-driven-frontend-adjustments",
"title": "参考原型调整前端页面",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-08-09",
"completedAt": "2026-08-10",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+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**: 5 - **Total Sessions**: 6
- **Last Active**: 2026-08-09 - **Last Active**: 2026-08-10
<!-- @@@/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` | ~142 | Active | | `journal-1.md` | ~164 | 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 |
|---|------|-------|---------|--------| |---|------|-------|---------|--------|
| 6 | 2026-08-10 | 按原型完善选股结果分页接口 | `ed7bdda`, `3af97bf` | `develop` |
| 5 | 2026-08-09 | 完成响应式前端交互原型设计 | `0511669` | `develop` | | 5 | 2026-08-09 | 完成响应式前端交互原型设计 | `0511669` | `develop` |
| 4 | 2026-08-09 | 完成知行 B1 策略迁移 | `e9d06df` | `develop` | | 4 | 2026-08-09 | 完成知行 B1 策略迁移 | `e9d06df` | `develop` |
| 3 | 2026-08-08 | 完成 00 Bootstrap Guidelines | `ee00641` | `develop` | | 3 | 2026-08-08 | 完成 00 Bootstrap Guidelines | `ee00641` | `develop` |
+22
View File
@@ -140,3 +140,25 @@
### Next Steps ### Next Steps
- 生产实现另建任务,按 pathless layout、页面迁移和 shared pagination 顺序推进 - 生产实现另建任务,按 pathless layout、页面迁移和 shared pagination 顺序推进
## Session 6: 按原型完善选股结果分页接口
**Date**: 2026-08-10
**Task**: 按原型完善选股结果分页接口
**Branch**: `develop`
### Summary
完成响应式研究工作台前端原型调整,并为选股结果补充服务端分页、搜索与分类筛选接口;同步前端查询状态、分页组件和局部成功布局,后端 54 passed/1 skipped、前端 20 passed,Lint、类型检查和生产构建通过。已提交 3af97bf 并归档任务。
### Git Commits
| Hash | Message |
|------|---------|
| `ed7bdda` | (see git log) |
| `3af97bf` | (see git log) |
### Status
[OK] **Completed**
@@ -11,6 +11,7 @@ from ..domain.models import SelectionEvaluation
from ..domain.runs import ( from ..domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery,
SelectionRun, SelectionRun,
SelectionRunInProgress, SelectionRunInProgress,
SelectionRunItem, SelectionRunItem,
@@ -135,19 +136,26 @@ class RunZhixingB1:
except Exception: # noqa: BLE001 - preserve the original worker failure except Exception: # noqa: BLE001 - preserve the original worker failure
logger.exception("selection_run_failure_persist_failed run_id=%s", prepared.run.id) logger.exception("selection_run_failure_persist_failed run_id=%s", prepared.run.id)
def get_run(self, run_id: str) -> SelectionRun | None: def get_run(
self,
run_id: str,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None:
"""Read one persisted run for polling.""" """Read one persisted run for polling."""
return self.store.get_run(run_id) return self.store.get_run(run_id, query=query)
def get_latest( def get_latest(
self, self,
strategy: StrategyName, strategy: StrategyName,
target_trade_date: date | None = None, target_trade_date: date | None = None,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None: ) -> SelectionRun | None:
"""Read the current result by date or the latest result for a strategy.""" """Read the current result by date or the latest result for a strategy."""
return self.store.get_latest_run(strategy, target_trade_date) return self.store.get_latest_run(strategy, target_trade_date, query=query)
def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> SelectionRunItem: def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> SelectionRunItem:
@@ -11,6 +11,17 @@ from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory
SelectionRunStatus = Literal["running", "success", "partial_success", "failed"] SelectionRunStatus = Literal["running", "success", "partial_success", "failed"]
SelectionRunItemStatus = SelectionEvaluationStatus SelectionRunItemStatus = SelectionEvaluationStatus
SelectionSignalCategoryFilter = Literal["pullback", "oversold", "original"]
@dataclass(frozen=True, slots=True)
class SelectionResultQuery:
"""Validated query options for a paged selection-result read."""
page: int = 1
page_size: int = 10
search: str | None = None
category: SelectionSignalCategoryFilter | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -67,6 +78,7 @@ class SelectionRun:
finished_at: datetime | None = None finished_at: datetime | None = None
items: tuple[SelectionRunItem, ...] = field(default_factory=tuple) items: tuple[SelectionRunItem, ...] = field(default_factory=tuple)
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple) signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
signals_total: int | None = None
class SelectionRunError(RuntimeError): class SelectionRunError(RuntimeError):
@@ -112,12 +124,19 @@ class SelectionRunStore(Protocol):
error_message: str | None = None, error_message: str | None = None,
) -> None: ... ) -> None: ...
def get_run(self, run_id: str) -> SelectionRun | None: ... def get_run(
self,
run_id: str,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None: ...
def get_latest_run( def get_latest_run(
self, self,
strategy: Literal["zhixing_b1"], strategy: Literal["zhixing_b1"],
target_trade_date: date | None = None, target_trade_date: date | None = None,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None: ... ) -> SelectionRun | None: ...
@@ -18,6 +18,7 @@ from ..domain.models import SelectionSignal, ZhixingB1Category
from ..domain.runs import ( from ..domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery,
SelectionRun, SelectionRun,
SelectionRunError, SelectionRunError,
SelectionRunInProgress, SelectionRunInProgress,
@@ -29,6 +30,19 @@ from ..domain.runs import (
from ..domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER from ..domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER
_SIGNAL_PRIORITY = {category: index for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER)} _SIGNAL_PRIORITY = {category: index for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER)}
_CATEGORY_PREFIXES = {
"pullback": "zhixing_b1_pullback_",
"oversold": "zhixing_b1_oversold_",
"original": "zhixing_b1_original_b1",
}
_SIGNAL_ORDER_SQL = (
"CASE category "
+ " ".join(
f"WHEN '{category.value}' THEN {index}"
for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER)
)
+ f" ELSE {len(ZHIXING_B1_SIGNAL_ORDER)} END"
)
class PostgresSelectionRunRepository(SelectionRunStore): class PostgresSelectionRunRepository(SelectionRunStore):
@@ -224,12 +238,17 @@ class PostgresSelectionRunRepository(SelectionRunStore):
except psycopg.Error as exc: except psycopg.Error as exc:
raise SelectionRunStoreError(f"failed to finish selection run {run_id}") from exc raise SelectionRunStoreError(f"failed to finish selection run {run_id}") from exc
def get_run(self, run_id: str) -> SelectionRun | None: def get_run(
"""Read one run with its item failures and signal details.""" self,
run_id: str,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None:
"""Read one run with filtered, paged signals and item failures."""
try: try:
with self._connection() as connection: with self._connection() as connection:
return self._load_run(connection, run_id) return self._load_run(connection, run_id, query or SelectionResultQuery())
except psycopg.Error as exc: except psycopg.Error as exc:
raise SelectionRunStoreError(f"failed to load selection run {run_id}") from exc raise SelectionRunStoreError(f"failed to load selection run {run_id}") from exc
@@ -237,6 +256,8 @@ class PostgresSelectionRunRepository(SelectionRunStore):
self, self,
strategy: Literal["zhixing_b1"], strategy: Literal["zhixing_b1"],
target_trade_date: date | None = None, target_trade_date: date | None = None,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None: ) -> SelectionRun | None:
"""Read the current run for a date or the latest date for a strategy.""" """Read the current run for a date or the latest date for a strategy."""
@@ -263,12 +284,20 @@ class PostgresSelectionRunRepository(SelectionRunStore):
""", """,
(strategy, target_trade_date), (strategy, target_trade_date),
).fetchone() ).fetchone()
return self._load_run(connection, str(row[0])) if row else None return (
self._load_run(connection, str(row[0]), query or SelectionResultQuery())
if row
else None
)
except psycopg.Error as exc: except psycopg.Error as exc:
raise SelectionRunStoreError("failed to load latest selection run") from exc raise SelectionRunStoreError("failed to load latest selection run") from exc
@staticmethod @staticmethod
def _load_run(connection: Any, run_id: str) -> SelectionRun | None: def _load_run(
connection: Any,
run_id: str,
query: SelectionResultQuery,
) -> SelectionRun | None:
row = connection.execute( row = connection.execute(
""" """
SELECT SELECT
@@ -292,15 +321,23 @@ class PostgresSelectionRunRepository(SelectionRunStore):
""", """,
(run_id,), (run_id,),
).fetchall() ).fetchall()
signal_filter, signal_parameters = _signal_filter(query, run_id)
signal_total_row = connection.execute(
f"SELECT COUNT(*) FROM selection_signal WHERE {signal_filter}",
tuple(signal_parameters),
).fetchone()
signal_total = int(signal_total_row[0] or 0) if signal_total_row else 0
offset = (query.page - 1) * query.page_size
signal_rows = connection.execute( signal_rows = connection.execute(
""" f"""
SELECT SELECT
ts_code, name, target_trade_date, strategy, category, close, details ts_code, name, target_trade_date, strategy, category, close, details
FROM selection_signal FROM selection_signal
WHERE run_id = %s WHERE {signal_filter}
ORDER BY ts_code, category ORDER BY ts_code, {_SIGNAL_ORDER_SQL}
LIMIT %s OFFSET %s
""", """,
(run_id,), tuple((*signal_parameters, query.page_size, offset)),
).fetchall() ).fetchall()
signals = tuple( signals = tuple(
sorted( sorted(
@@ -353,6 +390,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
finished_at=cast(datetime | None, row[15]), finished_at=cast(datetime | None, row[15]),
items=items, items=items,
signals=signals, signals=signals,
signals_total=signal_total,
) )
@contextmanager @contextmanager
@@ -380,6 +418,27 @@ def _signal_from_row(row: tuple[object, ...]) -> SelectionSignal:
) )
def _signal_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[object]]:
"""Build the parameterized WHERE clause shared by count and page reads."""
clauses = ["run_id = %s"]
parameters: list[object] = [run_id]
if query.search:
pattern = f"%{_escape_like(query.search)}%"
clauses.append("(name ILIKE %s ESCAPE '\\' OR ts_code ILIKE %s ESCAPE '\\')")
parameters.extend((pattern, pattern))
if query.category:
clauses.append("category LIKE %s")
parameters.append(f"{_CATEGORY_PREFIXES[query.category]}%")
return " AND ".join(clauses), parameters
def _escape_like(value: str) -> str:
"""Escape user wildcards before placing text inside a SQL LIKE pattern."""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _details(value: object) -> dict[str, float | str | None]: def _details(value: object) -> dict[str, float | str | None]:
"""Normalize JSONB details into the domain's scalar-only mapping.""" """Normalize JSONB details into the domain's scalar-only mapping."""
@@ -3,7 +3,7 @@
from datetime import date, datetime from datetime import date, datetime
from typing import Annotated, Literal from typing import Annotated, Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from zhixing_server.bootstrap.config import Settings, get_settings from zhixing_server.bootstrap.config import Settings, get_settings
@@ -12,6 +12,7 @@ from zhixing_server.modules.selection.application.run import (
) )
from zhixing_server.modules.selection.domain.runs import ( from zhixing_server.modules.selection.domain.runs import (
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery,
SelectionRun, SelectionRun,
SelectionRunInProgress, SelectionRunInProgress,
SelectionRunStoreError, SelectionRunStoreError,
@@ -88,7 +89,7 @@ def _empty_signals() -> list[SelectionSignalResponse]:
class SelectionResultsResponse(BaseModel): class SelectionResultsResponse(BaseModel):
"""Batch summary and materialized signals consumed by the Web feature.""" """Batch summary and one filtered page of signals consumed by the Web feature."""
strategy: StrategyValue strategy: StrategyValue
target_trade_date: date | None target_trade_date: date | None
@@ -106,6 +107,9 @@ class SelectionResultsResponse(BaseModel):
error_message: str | None = None error_message: str | None = None
created_at: datetime | None = None created_at: datetime | None = None
finished_at: datetime | None = None finished_at: datetime | None = None
page: int = Field(default=1, ge=1)
page_size: int = Field(default=10, ge=1, le=100)
signals_total: int = Field(default=0, ge=0)
failures: list[SelectionFailureResponse] = Field(default_factory=_empty_failures) failures: list[SelectionFailureResponse] = Field(default_factory=_empty_failures)
signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals) signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals)
@@ -160,16 +164,21 @@ def trigger_selection_run(
def get_selection_run( def get_selection_run(
run_id: str, run_id: str,
service: Annotated[RunZhixingB1, Depends(get_selection_service)], service: Annotated[RunZhixingB1, Depends(get_selection_service)],
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 10,
search: Annotated[str | None, Query(max_length=100)] = None,
category: Literal["pullback", "oversold", "original"] | None = None,
) -> SelectionResultsResponse: ) -> SelectionResultsResponse:
"""Return one run for asynchronous polling.""" """Return one run for asynchronous polling."""
try: try:
run = service.get_run(run_id) query = _result_query(page, page_size, search, category)
run = service.get_run(run_id, query=query)
except SelectionRunStoreError as exc: except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
if run is None: if run is None:
raise _http_error(404, "run_not_found", f"selection run not found: {run_id}") raise _http_error(404, "run_not_found", f"selection run not found: {run_id}")
return _run_response(run) return _run_response(run, query=query)
@selection_router.get("/results", response_model=SelectionResultsResponse) @selection_router.get("/results", response_model=SelectionResultsResponse)
@@ -177,11 +186,16 @@ def get_selection_results(
service: Annotated[RunZhixingB1, Depends(get_selection_service)], service: Annotated[RunZhixingB1, Depends(get_selection_service)],
strategy: StrategyValue = "zhixing_b1", strategy: StrategyValue = "zhixing_b1",
target_trade_date: date | None = None, target_trade_date: date | None = None,
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 10,
search: Annotated[str | None, Query(max_length=100)] = None,
category: Literal["pullback", "oversold", "original"] | None = None,
) -> SelectionResultsResponse: ) -> SelectionResultsResponse:
"""Return the current persisted result for a strategy and optional date.""" """Return the current persisted result for a strategy and optional date."""
try: try:
run = service.get_latest(strategy, target_trade_date) query = _result_query(page, page_size, search, category)
run = service.get_latest(strategy, target_trade_date, query=query)
except SelectionRunStoreError as exc: except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
if run is None: if run is None:
@@ -192,11 +206,14 @@ def get_selection_results(
market_sync_batch_id=None, market_sync_batch_id=None,
status="no_data", status="no_data",
coverage=0, coverage=0,
page=query.page,
page_size=query.page_size,
signals_total=0,
) )
return _run_response(run) return _run_response(run, query=query)
def _run_response(run: SelectionRun) -> SelectionResultsResponse: def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> SelectionResultsResponse:
"""Translate a domain run without exposing storage-specific fields.""" """Translate a domain run without exposing storage-specific fields."""
return SelectionResultsResponse( return SelectionResultsResponse(
@@ -216,6 +233,9 @@ def _run_response(run: SelectionRun) -> SelectionResultsResponse:
error_message=run.error_message, error_message=run.error_message,
created_at=run.created_at, created_at=run.created_at,
finished_at=run.finished_at, finished_at=run.finished_at,
page=query.page,
page_size=query.page_size,
signals_total=run.signals_total if run.signals_total is not None else run.signal_count,
failures=[ failures=[
SelectionFailureResponse( SelectionFailureResponse(
ts_code=item.ts_code, ts_code=item.ts_code,
@@ -241,6 +261,23 @@ def _run_response(run: SelectionRun) -> SelectionResultsResponse:
) )
def _result_query(
page: int,
page_size: int,
search: str | None,
category: Literal["pullback", "oversold", "original"] | None,
) -> SelectionResultQuery:
"""Normalize HTTP query values before handing them to the selection port."""
normalized_search = search.strip() if search else None
return SelectionResultQuery(
page=page,
page_size=page_size,
search=normalized_search or None,
category=category,
)
def _http_error(code: int, error_type: str, message: str) -> HTTPException: def _http_error(code: int, error_type: str, message: str) -> HTTPException:
"""Create the project's explicit, safe error envelope.""" """Create the project's explicit, safe error envelope."""
+49 -1
View File
@@ -11,6 +11,7 @@ from zhixing_server.modules.selection.domain.models import SelectionSignal, Zhix
from zhixing_server.modules.selection.domain.runs import ( from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery,
SelectionRun, SelectionRun,
SelectionRunInProgress, SelectionRunInProgress,
SelectionStock, SelectionStock,
@@ -28,6 +29,7 @@ class FakeSelectionService:
self.run = run self.run = run
self.executed = False self.executed = False
self.mode = "ok" self.mode = "ok"
self.last_query: SelectionResultQuery | None = None
def prepare( def prepare(
self, self,
@@ -58,14 +60,23 @@ class FakeSelectionService:
def execute(self, prepared: PreparedSelectionRun) -> None: def execute(self, prepared: PreparedSelectionRun) -> None:
self.executed = True self.executed = True
def get_run(self, run_id: str) -> SelectionRun | None: def get_run(
self,
run_id: str,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None:
self.last_query = query
return self.run if self.run and self.run.id == run_id else None return self.run if self.run and self.run.id == run_id else None
def get_latest( def get_latest(
self, self,
strategy: str, strategy: str,
target_trade_date: date | None = None, target_trade_date: date | None = None,
*,
query: SelectionResultQuery | None = None,
) -> SelectionRun | None: ) -> SelectionRun | None:
self.last_query = query
if self.run is None: if self.run is None:
return None return None
if target_trade_date is not None and self.run.target_trade_date != target_trade_date: if target_trade_date is not None and self.run.target_trade_date != target_trade_date:
@@ -193,10 +204,47 @@ def test_query_returns_persisted_signal_details() -> None:
body = response.json() body = response.json()
assert body["run_id"] == "run-http" assert body["run_id"] == "run-http"
assert body["signal_count"] == 1 assert body["signal_count"] == 1
assert body["page"] == 1
assert body["page_size"] == 10
assert body["signals_total"] == 1
assert body["signals"][0]["category"] == "zhixing_b1_original_b1" assert body["signals"][0]["category"] == "zhixing_b1_original_b1"
assert body["signals"][0]["details"] == {"j": 12.0} assert body["signals"][0]["details"] == {"j": 12.0}
def test_query_forwards_pagination_and_filters() -> None:
service = FakeSelectionService(_run("run-http", "success"))
response = _client(service).get(
"/api/v1/selection/results",
params={
"strategy": "zhixing_b1",
"page": 2,
"page_size": 5,
"search": " 平安银行 ",
"category": "original",
},
)
assert response.status_code == 200
assert service.last_query == SelectionResultQuery(
page=2,
page_size=5,
search="平安银行",
category="original",
)
assert response.json()["page"] == 2
assert response.json()["page_size"] == 5
def test_query_rejects_invalid_page_size() -> None:
response = _client(FakeSelectionService()).get(
"/api/v1/selection/results",
params={"strategy": "zhixing_b1", "page_size": 101},
)
assert response.status_code == 422
def test_run_polling_returns_the_persisted_terminal_result() -> None: def test_run_polling_returns_the_persisted_terminal_result() -> None:
response = _client(FakeSelectionService(_run("run-http", "success"))).get( response = _client(FakeSelectionService(_run("run-http", "success"))).get(
"/api/v1/selection/runs/run-http" "/api/v1/selection/runs/run-http"
@@ -11,6 +11,7 @@ from zhixing_server.modules.selection.domain.models import SelectionSignal, Zhix
from zhixing_server.modules.selection.domain.runs import ( from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery,
SelectionRunInProgress, SelectionRunInProgress,
SelectionRunItem, SelectionRunItem,
SelectionStock, SelectionStock,
@@ -163,6 +164,9 @@ def test_record_item_persists_independent_signals_as_jsonb(monkeypatch: pytest.M
class LoadConnection: class LoadConnection:
def __init__(self) -> None:
self.statements: list[tuple[str, tuple[object, ...]]] = []
def __enter__(self) -> "LoadConnection": def __enter__(self) -> "LoadConnection":
return self return self
@@ -170,6 +174,7 @@ class LoadConnection:
return None return None
def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult": def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult":
self.statements.append((query, parameters))
if "FROM selection_run\n" in query: if "FROM selection_run\n" in query:
return LoadResult( return LoadResult(
row=( row=(
@@ -193,6 +198,8 @@ class LoadConnection:
) )
if "FROM selection_run_item" in query: if "FROM selection_run_item" in query:
return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)]) return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)])
if "COUNT(*) FROM selection_signal" in query:
return LoadResult(row=(2,))
return LoadResult( return LoadResult(
rows=[ rows=[
( (
@@ -242,10 +249,32 @@ def test_get_run_orders_signals_by_formula_priority(monkeypatch: pytest.MonkeyPa
return connection return connection
monkeypatch.setattr(psycopg, "connect", connect) monkeypatch.setattr(psycopg, "connect", connect)
run = PostgresSelectionRunRepository("postgresql://test").get_run("run-1") run = PostgresSelectionRunRepository("postgresql://test").get_run(
"run-1",
query=SelectionResultQuery(
page=2,
page_size=1,
search="100%",
category="pullback",
),
)
assert run is not None assert run is not None
assert [signal.category for signal in run.signals] == [ assert [signal.category for signal in run.signals] == [
ZHIXING_B1_SIGNAL_ORDER[0], ZHIXING_B1_SIGNAL_ORDER[0],
ZHIXING_B1_SIGNAL_ORDER[-1], ZHIXING_B1_SIGNAL_ORDER[-1],
] ]
count_query, count_parameters = next(
(query, parameters)
for query, parameters in connection.statements
if "COUNT(*) FROM selection_signal" in query
)
assert "name ILIKE %s ESCAPE" in count_query
assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%")
page_query, page_parameters = next(
(query, parameters)
for query, parameters in connection.statements
if "LIMIT %s OFFSET %s" in query
)
assert "ORDER BY ts_code, CASE category" in page_query
assert page_parameters[-2:] == (1, 1)
@@ -15,6 +15,7 @@ from zhixing_server.modules.selection.domain.models import (
) )
from zhixing_server.modules.selection.domain.runs import ( from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionResultQuery,
SelectionRun, SelectionRun,
SelectionRunItem, SelectionRunItem,
SelectionRunStatus, SelectionRunStatus,
@@ -100,10 +101,16 @@ class FakeStore:
kwargs["error_message"] = error_message kwargs["error_message"] = error_message
self.finished = (run_id, status, kwargs) self.finished = (run_id, status, kwargs)
def get_run(self, run_id: str): def get_run(self, run_id: str, *, query: SelectionResultQuery | None = None):
return None return None
def get_latest_run(self, strategy: str, target_trade_date: date | None = None): def get_latest_run(
self,
strategy: str,
target_trade_date: date | None = None,
*,
query: SelectionResultQuery | None = None,
):
return None return None
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const requestJson = vi.hoisted(() => vi.fn())
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
import { getSelectionResults, getSelectionRun } from "./selection.api"
describe("selection API adapters", () => {
beforeEach(() => {
requestJson.mockReset()
})
it("sends paged result filters using the HTTP field names", async () => {
await getSelectionResults("zhixing_b1", "2026-08-08", {
category: "pullback",
page: 2,
pageSize: 15,
search: "平安银行",
})
const [input, init] = requestJson.mock.calls[0] as [
string,
{ signal?: AbortSignal },
]
const params = new URL(input, "http://localhost").searchParams
expect(params.get("strategy")).toBe("zhixing_b1")
expect(params.get("target_trade_date")).toBe("2026-08-08")
expect(params.get("page")).toBe("2")
expect(params.get("page_size")).toBe("15")
expect(params.get("search")).toBe("平安银行")
expect(params.get("category")).toBe("pullback")
expect(init).toEqual({ signal: undefined })
})
it("uses the same query contract while polling a run", async () => {
await getSelectionRun("run-1", { page: 3, pageSize: 5 })
const [input] = requestJson.mock.calls[0] as [string]
const params = new URL(input, "http://localhost").searchParams
expect(input).toContain("/api/v1/selection/runs/run-1?")
expect(params.get("page")).toBe("3")
expect(params.get("page_size")).toBe("5")
expect(params.has("search")).toBe(false)
expect(params.has("category")).toBe(false)
})
})
@@ -2,6 +2,7 @@ import { requestJson } from "@/shared/api/request-json"
import type { import type {
SelectionResults, SelectionResults,
SelectionResultsQuery,
SelectionRunAccepted, SelectionRunAccepted,
SelectionRunRequest, SelectionRunRequest,
SelectionStrategy, SelectionStrategy,
@@ -10,9 +11,11 @@ import type {
export function getSelectionResults( export function getSelectionResults(
strategy: SelectionStrategy, strategy: SelectionStrategy,
targetTradeDate?: string, targetTradeDate?: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
signal?: AbortSignal, signal?: AbortSignal,
) { ) {
const params = new URLSearchParams({ strategy }) const params = buildSelectionQueryParams(query)
params.set("strategy", strategy)
if (targetTradeDate) params.set("target_trade_date", targetTradeDate) if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
return requestJson<SelectionResults>( return requestJson<SelectionResults>(
`/api/v1/selection/results?${params.toString()}`, `/api/v1/selection/results?${params.toString()}`,
@@ -20,10 +23,28 @@ export function getSelectionResults(
) )
} }
export function getSelectionRun(runId: string, signal?: AbortSignal) { export function getSelectionRun(
return requestJson<SelectionResults>(`/api/v1/selection/runs/${runId}`, { runId: string,
signal, query: SelectionResultsQuery = { page: 1, pageSize: 10 },
signal?: AbortSignal,
) {
const params = buildSelectionQueryParams(query)
return requestJson<SelectionResults>(
`/api/v1/selection/runs/${runId}?${params.toString()}`,
{
signal,
},
)
}
function buildSelectionQueryParams(query: SelectionResultsQuery) {
const params = new URLSearchParams({
page: String(query.page),
page_size: String(query.pageSize),
}) })
if (query.search) params.set("search", query.search)
if (query.category) params.set("category", query.category)
return params
} }
export function triggerSelectionRun( export function triggerSelectionRun(
@@ -11,33 +11,63 @@ import {
getSelectionRun, getSelectionRun,
triggerSelectionRun, triggerSelectionRun,
} from "./selection.api" } from "./selection.api"
import type { SelectionRunRequest, SelectionStrategy } from "./selection.types" import type {
SelectionResultsQuery,
SelectionRunRequest,
SelectionStrategy,
} from "./selection.types"
export const selectionResultsQueryKey = ( export const selectionResultsQueryKey = (
strategy: SelectionStrategy, strategy: SelectionStrategy,
targetTradeDate?: string, targetTradeDate?: string,
) => ["selection", "results", strategy, targetTradeDate] as const query: SelectionResultsQuery = { page: 1, pageSize: 10 },
) =>
[
"selection",
"results",
strategy,
targetTradeDate,
query.page,
query.pageSize,
query.search ?? "",
query.category ?? "all",
] as const
export const selectionRunQueryKey = (runId: string) => export const selectionRunQueryKey = (
["selection", "runs", runId] as const runId: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
) =>
[
"selection",
"runs",
runId,
query.page,
query.pageSize,
query.search ?? "",
query.category ?? "all",
] as const
export function useSelectionResults( export function useSelectionResults(
strategy: SelectionStrategy, strategy: SelectionStrategy,
targetTradeDate?: string, targetTradeDate?: string,
query: SelectionResultsQuery = { page: 1, pageSize: 10 },
) { ) {
return useQuery({ return useQuery({
queryFn: ({ signal }) => queryFn: ({ signal }) =>
getSelectionResults(strategy, targetTradeDate, signal), getSelectionResults(strategy, targetTradeDate, query, signal),
queryKey: selectionResultsQueryKey(strategy, targetTradeDate), queryKey: selectionResultsQueryKey(strategy, targetTradeDate, query),
}) })
} }
export function useSelectionRun(runId: string | null) { export function useSelectionRun(
runId: string | null,
resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 },
) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const query = useQuery({ const query = useQuery({
enabled: Boolean(runId), enabled: Boolean(runId),
queryFn: ({ signal }) => getSelectionRun(runId ?? "", signal), queryFn: ({ signal }) => getSelectionRun(runId ?? "", resultQuery, signal),
queryKey: selectionRunQueryKey(runId ?? "none"), queryKey: selectionRunQueryKey(runId ?? "none", resultQuery),
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.status === "running" ? 1500 : false, query.state.data?.status === "running" ? 1500 : false,
}) })
@@ -1,5 +1,22 @@
export type SelectionStrategy = "zhixing_b1" export type SelectionStrategy = "zhixing_b1"
export type SelectionCategoryFilter =
"all" | "pullback" | "oversold" | "original"
export const selectionCategoryFilters = [
"all",
"pullback",
"oversold",
"original",
] as const
export interface SelectionResultsQuery {
page: number
pageSize: number
search?: string
category?: Exclude<SelectionCategoryFilter, "all">
}
export type SelectionRunStatus = export type SelectionRunStatus =
"no_data" | "running" | "success" | "partial_success" | "failed" "no_data" | "running" | "success" | "partial_success" | "failed"
@@ -50,6 +67,9 @@ export interface SelectionResults {
error_message: string | null error_message: string | null
created_at: string | null created_at: string | null
finished_at: string | null finished_at: string | null
page: number
page_size: number
signals_total: number
failures: SelectionFailure[] failures: SelectionFailure[]
signals: SelectionSignal[] signals: SelectionSignal[]
} }
@@ -1,4 +1,7 @@
import type { SelectionSignal } from "../api/selection.types" import type {
SelectionCategoryFilter,
SelectionSignal,
} from "../api/selection.types"
export const categoryLabels: Record<string, string> = { export const categoryLabels: Record<string, string> = {
zhixing_b1_extreme_volume: "超卖超缩量 B", zhixing_b1_extreme_volume: "超卖超缩量 B",
@@ -10,7 +13,7 @@ export const categoryLabels: Record<string, string> = {
zhixing_b1_pullback_yellow: "回踩黄线 B", zhixing_b1_pullback_yellow: "回踩黄线 B",
} }
export type SignalCategoryFilter = "all" | "pullback" | "oversold" | "original" export type SignalCategoryFilter = SelectionCategoryFilter
export const signalCategoryOptions: ReadonlyArray<{ export const signalCategoryOptions: ReadonlyArray<{
label: string label: string
@@ -15,7 +15,7 @@ import {
} from "./selection-presentation" } from "./selection-presentation"
import { SignalTable } from "./signal-table" import { SignalTable } from "./signal-table"
const PAGE_SIZE_OPTIONS = [5, 10, 20] as const const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
interface SelectionResultsWorkbenchProps { interface SelectionResultsWorkbenchProps {
result: SelectionResults result: SelectionResults
@@ -26,14 +26,14 @@ export function SelectionResultsWorkbench({
}: SelectionResultsWorkbenchProps) { }: SelectionResultsWorkbenchProps) {
const search = useSearch({ from: "/_workspace/selection" }) const search = useSearch({ from: "/_workspace/selection" })
const navigate = useNavigate({ from: "/selection" }) const navigate = useNavigate({ from: "/selection" })
const [query, setQuery] = useState("") const query = search.search ?? ""
const [category, setCategory] = useState<SignalCategoryFilter>("all") const category = search.category ?? "all"
const [selectedKey, setSelectedKey] = useState<string | null>(null) const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>( const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
new Set(), new Set(),
) )
const filteredSignals = useMemo(() => { const visibleSignals = useMemo(() => {
const normalized = query.trim().toLowerCase() const normalized = query.trim().toLowerCase()
return result.signals.filter((signal) => { return result.signals.filter((signal) => {
const matchesQuery = const matchesQuery =
@@ -46,12 +46,10 @@ export function SelectionResultsWorkbench({
const page = search.page const page = search.page
const pageSize = search.pageSize const pageSize = search.pageSize
const pageCount = Math.max(1, Math.ceil(filteredSignals.length / pageSize)) const signalsTotal = result.signals_total ?? result.signal_count
const pageCount = Math.max(1, Math.ceil(signalsTotal / pageSize))
const currentPage = Math.min(page, pageCount) const currentPage = Math.min(page, pageCount)
const currentSignals = filteredSignals.slice( const currentSignals = visibleSignals
(currentPage - 1) * pageSize,
currentPage * pageSize,
)
const selectedSignal = const selectedSignal =
currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ?? currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ??
currentSignals[0] currentSignals[0]
@@ -64,18 +62,21 @@ export function SelectionResultsWorkbench({
} }
}, [navigate, page, pageCount]) }, [navigate, page, pageCount])
function updateSearch(next: { page?: number; pageSize?: number }) { function updateSearch(next: {
page?: number
pageSize?: number
search?: string
category?: SignalCategoryFilter
}) {
void navigate({ search: (previous) => ({ ...previous, ...next }) }) void navigate({ search: (previous) => ({ ...previous, ...next }) })
} }
function handleQueryChange(value: string) { function handleQueryChange(value: string) {
setQuery(value) updateSearch({ page: 1, search: value })
updateSearch({ page: 1 })
} }
function handleCategoryChange(value: SignalCategoryFilter) { function handleCategoryChange(value: SignalCategoryFilter) {
setCategory(value) updateSearch({ category: value, page: 1 })
updateSearch({ page: 1 })
} }
function handleToggleExpanded(signal: SelectionSignal) { function handleToggleExpanded(signal: SelectionSignal) {
@@ -115,7 +116,7 @@ export function SelectionResultsWorkbench({
))} ))}
</select> </select>
<span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end"> <span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end">
筛选结果 {filteredSignals.length} 条 筛选结果 {signalsTotal} 条
</span> </span>
</div> </div>
@@ -141,7 +142,7 @@ export function SelectionResultsWorkbench({
page={currentPage} page={currentPage}
pageSize={pageSize} pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS} pageSizeOptions={PAGE_SIZE_OPTIONS}
total={filteredSignals.length} total={signalsTotal}
/> />
</section> </section>
@@ -20,7 +20,7 @@ vi.mock("@/features/selection/api/selection.query", () => ({
vi.mock("@tanstack/react-router", () => ({ vi.mock("@tanstack/react-router", () => ({
useNavigate: () => routerNavigate, useNavigate: () => routerNavigate,
useSearch: () => ({ page: 1, pageSize: 5 }), useSearch: () => ({ page: 1, pageSize: 5, search: "", category: "all" }),
})) }))
const selectedResult: SelectionResults = { const selectedResult: SelectionResults = {
@@ -34,9 +34,12 @@ const selectedResult: SelectionResults = {
failures: [], failures: [],
finished_at: "2026-08-08T09:02:00+08:00", finished_at: "2026-08-08T09:02:00+08:00",
market_sync_batch_id: "market-run-1", market_sync_batch_id: "market-run-1",
page: 1,
page_size: 5,
run_id: "run-1", run_id: "run-1",
selected_stock_count: 1, selected_stock_count: 1,
signal_count: 2, signal_count: 2,
signals_total: 2,
signals: [ signals: [
{ {
category: "zhixing_b1_original_b1", category: "zhixing_b1_original_b1",
@@ -142,7 +145,12 @@ describe("SelectionResultsPage", () => {
ts_code: `00000${index + 1}.SZ`, ts_code: `00000${index + 1}.SZ`,
})) }))
useSelectionResults.mockReturnValue({ useSelectionResults.mockReturnValue({
data: { ...selectedResult, signal_count: signals.length, signals }, data: {
...selectedResult,
signal_count: signals.length,
signals,
signals_total: signals.length,
},
isError: false, isError: false,
isPending: false, isPending: false,
}) })
@@ -262,7 +270,10 @@ describe("SelectionResultsPage", () => {
expect(screen.getByText("策略执行中")).toBeInTheDocument() expect(screen.getByText("策略执行中")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled() expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled()
expect(useSelectionRun).toHaveBeenCalledWith("run-1") expect(useSelectionRun).toHaveBeenCalledWith("run-1", {
page: 1,
pageSize: 5,
})
}) })
it("renders query failures explicitly", () => { it("renders query failures explicitly", () => {
@@ -5,6 +5,7 @@ import {
RefreshCw, RefreshCw,
Search, Search,
} from "lucide-react" } from "lucide-react"
import { useSearch } from "@tanstack/react-router"
import { useState } from "react" import { useState } from "react"
import { PageLayout } from "@/app/layout/page-layout" import { PageLayout } from "@/app/layout/page-layout"
@@ -15,6 +16,7 @@ import {
} from "@/features/selection/api/selection.query" } from "@/features/selection/api/selection.query"
import type { import type {
SelectionResults, SelectionResults,
SelectionResultsQuery,
SelectionRunStatus, SelectionRunStatus,
} from "@/features/selection/api/selection.types" } from "@/features/selection/api/selection.types"
import { Badge } from "@/shared/ui/badge" import { Badge } from "@/shared/ui/badge"
@@ -54,12 +56,23 @@ export function SelectionResultsPage() {
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null) const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
const [activeRunId, setActiveRunId] = useState<string | null>(null) const [activeRunId, setActiveRunId] = useState<string | null>(null)
const [rerunDialogOpen, setRerunDialogOpen] = useState(false) const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
const search = useSearch({ from: "/_workspace/selection" })
const resultQuery: SelectionResultsQuery = {
page: search.page,
pageSize: search.pageSize,
...(search.search ? { search: search.search } : {}),
...(search.category !== "all" ? { category: search.category } : {}),
}
const results = useSelectionResults(STRATEGY, targetTradeDate || undefined) const results = useSelectionResults(
STRATEGY,
targetTradeDate || undefined,
resultQuery,
)
const persistedRunningRunId = const persistedRunningRunId =
results.data?.status === "running" ? results.data.run_id : null results.data?.status === "running" ? results.data.run_id : null
const polledRunId = activeRunId ?? persistedRunningRunId const polledRunId = activeRunId ?? persistedRunningRunId
const activeRun = useSelectionRun(polledRunId) const activeRun = useSelectionRun(polledRunId, resultQuery)
const trigger = useTriggerSelectionRun() const trigger = useTriggerSelectionRun()
const selectedTargetTradeDate = const selectedTargetTradeDate =
@@ -420,7 +433,9 @@ function ResultState({ result }: { result: SelectionResults }) {
{result.signal_count === 0 ? ( {result.signal_count === 0 ? (
<NoSignalState /> <NoSignalState />
) : ( ) : (
<SelectionResultsWorkbench result={result} /> <div className="min-h-[420px] shrink-0">
<SelectionResultsWorkbench result={result} />
</div>
)} )}
{result.failures.length > 0 ? <FailureList result={result} /> : null} {result.failures.length > 0 ? <FailureList result={result} /> : null}
</div> </div>
+15 -2
View File
@@ -2,6 +2,10 @@ import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
import { AppLayout } from "@/app/layout/app-layout" import { AppLayout } from "@/app/layout/app-layout"
import { HomePage } from "@/features/home/pages/home-page" import { HomePage } from "@/features/home/pages/home-page"
import {
selectionCategoryFilters,
type SelectionCategoryFilter,
} from "@/features/selection/api/selection.types"
import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page" import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page"
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
@@ -28,8 +32,17 @@ const selectionRoute = createRoute({
const rawPageSize = Number(search.pageSize) const rawPageSize = Number(search.pageSize)
const page = const page =
Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1 Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
const pageSize = [5, 10, 20].includes(rawPageSize) ? rawPageSize : 10 const pageSize = [5, 10, 15].includes(rawPageSize) ? rawPageSize : 10
return { page, pageSize } const searchValue =
typeof search.search === "string" ? search.search.slice(0, 100) : ""
const rawCategory =
typeof search.category === "string" ? search.category : "all"
const category: SelectionCategoryFilter = selectionCategoryFilters.includes(
rawCategory as SelectionCategoryFilter,
)
? (rawCategory as SelectionCategoryFilter)
: "all"
return { page, pageSize, search: searchValue, category }
}, },
component: SelectionResultsPage, component: SelectionResultsPage,
}) })