Develop #20
@@ -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 为准,若环境允许再补常见视口截图。
|
||||||
+7
@@ -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、响应式断点、分页接口和迁移顺序。"}
|
||||||
+80
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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: ...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+68
-9
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user