Merge pull request 'feat(selection): 收紧执行状态交互与页面布局' (#8) from develop into main
Deploy Production / deploy (push) Successful in 23s
Deploy Production / deploy (push) Successful in 23s
Reviewed-on: sakibcc/zhixing-system#8
This commit was merged in pull request #8.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
{"file":".trellis/spec/frontend/index.md","reason":"检查前端 feature 边界、同源 API 契约和开发/质量入口。"}
|
||||||
|
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"检查抽屉、table 语义、交互元素和响应式样式是否符合规范。"}
|
||||||
|
{"file":".trellis/spec/frontend/state-management.md","reason":"检查局部抽屉状态、React Query 数据和 URL 分页状态边界。"}
|
||||||
|
{"file":".trellis/spec/frontend/type-safety.md","reason":"检查 SelectionFailure 类型复用和严格类型约束。"}
|
||||||
|
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"执行最终格式、lint、类型、测试、构建和审查门禁。"}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 选股执行状态抽屉与紧凑布局技术设计
|
||||||
|
|
||||||
|
## 1. Boundaries
|
||||||
|
|
||||||
|
本任务的产品边界在 `zhixing-web`:
|
||||||
|
|
||||||
|
- `SelectionResultsPage` 继续拥有执行状态、当前结果和局部抽屉开关状态。
|
||||||
|
- `features/selection/components` 新增执行状态抽屉与未完成评估表格,组件只消费 `SelectionFailure` 等 feature 类型。
|
||||||
|
- `PageLayout` 负责收紧跨页面的内容节奏;选股页面负责工作台内部的密度和滚动边界。
|
||||||
|
- `shared/ui/dialog.tsx` 作为底层弹层 primitive 复用,不新增后端或全局状态。
|
||||||
|
|
||||||
|
不改变 API adapter、query key、TanStack Query 状态和路由 search 参数。
|
||||||
|
|
||||||
|
## 2. Component Shape
|
||||||
|
|
||||||
|
建议的组件关系:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SelectionResultsPage
|
||||||
|
├─ PageLayout
|
||||||
|
│ ├─ ExecutionToolbar
|
||||||
|
│ ├─ ResultMetrics(onOpenExecutionStatus)
|
||||||
|
│ └─ ResultState
|
||||||
|
│ └─ SelectionResultsWorkbench
|
||||||
|
└─ ExecutionStatusDrawer
|
||||||
|
├─ status summary
|
||||||
|
└─ IncompleteEvaluationTable
|
||||||
|
```
|
||||||
|
|
||||||
|
`FailureList` 从 `ResultState` 移除。`ResultMetrics` 的执行状态入口通过回调通知页面,不把 Dialog 状态放进 Zustand,也不把失败列表复制到本地 state。
|
||||||
|
|
||||||
|
抽屉使用 `Dialog` 的受控 `open/onOpenChange`,通过 feature 局部 class 将 popup 定位到右侧、设置 `width: min(600px, 100vw)`、`height: 100svh` 和紧凑边框圆角。这样保留 Base UI 的焦点陷阱、Escape 和遮罩语义,同时避免为一次性业务交互新增浅层 shared primitive。
|
||||||
|
|
||||||
|
## 3. Data Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
SelectionResults.failures
|
||||||
|
│
|
||||||
|
├─ ResultMetrics: failure count/status trigger
|
||||||
|
└─ ExecutionStatusDrawer: read-only props
|
||||||
|
└─ IncompleteEvaluationTable: rows
|
||||||
|
```
|
||||||
|
|
||||||
|
抽屉不请求数据、不轮询、不修改 URL。抽屉展示的每一行直接对应 `SelectionFailure`,状态和原因只做前端文案格式化;缺失原因显示 `—`。
|
||||||
|
|
||||||
|
## 4. Layout and Density
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
|
||||||
|
- `AppLayout → main → PageLayout → ResultState → workbench` 保持连续的 `flex: 1`、`min-height: 0` 高度链。
|
||||||
|
- 操作区、指标区、筛选栏、分页均使用 `shrink-0`。
|
||||||
|
- 主从工作台使用剩余空间;表格 `overflow-auto`,分页留在面板底部。
|
||||||
|
- 页面 gutter 由当前 24px 收紧为 16px 级别;内部 panel/filter/detail 使用 8–16px 级别的间距。
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
|
||||||
|
- 保留日期、策略、执行按钮的语义和触控尺寸,不为了压缩而降低到桌面 32px。
|
||||||
|
- 内容容器采用紧凑 gutter 和较小 section gap;页面恢复自然滚动。
|
||||||
|
- 抽屉宽度随视口收缩,不产生横向溢出;表格必要时只在抽屉内部横向处理长文本,优先让原因换行。
|
||||||
|
|
||||||
|
## 5. Compatibility and Rollback
|
||||||
|
|
||||||
|
- 旧结果结构仍可直接渲染;只有失败列表的呈现位置改变。
|
||||||
|
- 若 Drawer 样式影响 Base UI Dialog,可回退为同一受控 Dialog 的居中呈现,不影响数据流和页面其他交互。
|
||||||
|
- 若高度链修复造成无结果/错误状态被压缩,保留这些状态的最小可读高度,并只让成功结果工作台承担剩余高度。
|
||||||
|
|
||||||
|
## 6. Verification Strategy
|
||||||
|
|
||||||
|
- 页面测试:抽屉打开、表格内容、空表状态、关闭和焦点入口;确认主页面不再直接渲染失败卡片。
|
||||||
|
- 质量门禁:Prettier、ESLint、TypeScript、Vitest、Vite build。
|
||||||
|
- 浏览器:使用可控 mock 结果验证 1440×900、1280×800、390×844、375×812;检查抽屉宽度、分页底部位置、数据区滚动和无水平溢出。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{"file":".trellis/spec/frontend/index.md","reason":"前端开发前检查、feature 边界、API 与布局约束。"}
|
||||||
|
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"抽屉、表格、组件组合、可访问性和 Tailwind 样式规范。"}
|
||||||
|
{"file":".trellis/spec/frontend/state-management.md","reason":"抽屉开关属于局部瞬时状态,服务器结果继续由 TanStack Query 管理。"}
|
||||||
|
{"file":".trellis/spec/frontend/type-safety.md","reason":"复用 SelectionFailure 类型并保持严格 TypeScript/API 边界。"}
|
||||||
|
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"实现后的格式、lint、类型、测试和构建门禁。"}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 选股执行状态抽屉与紧凑布局执行计划
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
只修改 `zhixing-web` 前端;后端接口、数据库和原型 HTML 不在本任务中变更。
|
||||||
|
|
||||||
|
## Ordered Checklist
|
||||||
|
|
||||||
|
### 1. 开发前上下文
|
||||||
|
|
||||||
|
- [x] 读取 `trellis-before-dev` 指定的前端规格和当前工作区状态。
|
||||||
|
- [x] 确认任务文件、用户现有改动和目标文件边界。
|
||||||
|
|
||||||
|
### 2. 执行状态抽屉
|
||||||
|
|
||||||
|
- [x] 新增 `selection` feature 内的执行状态抽屉组件,复用 `Dialog` primitive,桌面宽度固定 600px,移动端自适应。
|
||||||
|
- [x] 新增未完成评估 table 组件,展示名称、代码、状态、原因和空状态。
|
||||||
|
- [x] 将 `ResultMetrics` 的执行状态改为抽屉触发入口,移除主页面下方的 `FailureList`。
|
||||||
|
- [x] 保持 Escape、遮罩、关闭按钮和焦点返回行为。
|
||||||
|
|
||||||
|
### 3. 紧凑与有界布局
|
||||||
|
|
||||||
|
- [x] 收紧 `PageLayout` 的 gutter、区块 padding 和 vertical rhythm。
|
||||||
|
- [x] 收紧选股执行工具栏、指标、筛选栏、详情面板、分页和状态卡。
|
||||||
|
- [x] 修复成功结果工作台的 `flex/min-h-0` 高度链,确保数据区填满剩余高度、分页固定底部。
|
||||||
|
- [x] 保持移动端触控尺寸和自然页面滚动。
|
||||||
|
|
||||||
|
### 4. 测试与验证
|
||||||
|
|
||||||
|
- [x] 为抽屉、表格、空状态和主页面移除内联列表补充 Testing Library 测试。
|
||||||
|
- [x] 运行 `pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build`。
|
||||||
|
- [x] 用浏览器 mock 数据检查桌面和移动视口、抽屉宽度、滚动边界、分页位置及水平溢出。
|
||||||
|
|
||||||
|
## Verification Notes
|
||||||
|
|
||||||
|
- `pnpm lint`、`pnpm typecheck`、`pnpm test`(4 files / 22 tests)和 `pnpm build` 通过。
|
||||||
|
- 本次改动文件的 Prettier 检查通过;全量 `pnpm format:check` 仅因未修改的 `DESIGN.md` 未格式化而失败。
|
||||||
|
- 浏览器 mock 已验证 600px 桌面抽屉、移动端无水平溢出、关闭/Escape/遮罩/焦点恢复和工作台高度链。
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
在 `zhixing-web/` 下运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm format:check
|
||||||
|
pnpm lint
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risky Files and Rollback Points
|
||||||
|
|
||||||
|
- `src/features/selection/pages/selection-results-page.tsx`:页面状态和内联失败列表迁移;可单独回退组件接线。
|
||||||
|
- `src/features/selection/components/*execution*` / `*evaluation*`:新增抽屉与表格;删除新文件即可回退。
|
||||||
|
- `src/app/layout/page-layout.tsx`:跨页面密度和滚动边界;若影响 Home,回退该文件的密度 class,再保留 Selection 局部修复。
|
||||||
|
- `src/features/selection/components/selection-results-workbench.tsx`:高度链和数据滚动;回退时保留原筛选、分页和主从逻辑。
|
||||||
|
|
||||||
|
## Review Gates Before Start
|
||||||
|
|
||||||
|
- [x] `prd.md`、`design.md`、`implement.md` 已按最终范围收敛。
|
||||||
|
- [x] `implement.jsonl` 与 `check.jsonl` 已填入真实前端规格上下文。
|
||||||
|
- [x] 用户已批准本规划摘要后,才运行 `task.py start`。
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 收紧选股执行状态交互与页面布局
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
让选股策略页面把“未完成评估”从主工作区移出,改成由执行状态触发的右侧抽屉;抽屉内用紧凑表格查看明细。同时收紧页面各模块的内外间距,让桌面工作台填满可用高度,移动端保持可操作性。
|
||||||
|
|
||||||
|
本任务只修改前端交互和布局,不新增或修改后端接口。现有 API 返回的 `SelectionResults.failures` 作为抽屉数据源,接口扩展留给后续业务需求迭代。
|
||||||
|
|
||||||
|
## Confirmed Facts
|
||||||
|
|
||||||
|
- 当前 [selection-results-page.tsx](zhixing-web/src/features/selection/pages/selection-results-page.tsx) 在 `ResultState` 下方直接渲染 `FailureList`,标题为“未完成评估”,每条记录是独立卡片(约第 458–491 行)。
|
||||||
|
- 当前 `ResultMetrics` 已展示“执行状态”,但它只是 Badge,不承担交互(约第 290–315 行)。
|
||||||
|
- 当前选股结果工作台使用 `min-h-[420px] shrink-0` 包裹,导致桌面端数据面板不能填满页面剩余高度(约第 430–441 行)。
|
||||||
|
- 当前项目已有基于 Base UI Dialog 的可访问弹层 primitive,可复用其焦点管理、Escape 和遮罩行为;尚无 Drawer/Sheet primitive。
|
||||||
|
- 当前 `SelectionResults` 已包含 `failures`,字段为股票代码、名称、状态和原因;本任务不需要后端改契约。
|
||||||
|
- 当前分页、筛选和主从详情已经存在,本任务保留其行为。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### R1. 执行状态抽屉
|
||||||
|
|
||||||
|
- 主页面不再直接渲染“未完成评估”卡片或明细列表。
|
||||||
|
- “执行状态”成为可操作入口;点击后从右侧打开执行状态抽屉,并把当前结果的未完成评估列表传入抽屉。
|
||||||
|
- 抽屉标题和说明要明确表达当前策略、目标交易日、执行状态及未完成数量。
|
||||||
|
- 桌面端抽屉宽度固定为 `600px`;视口小于该宽度时退化为不产生水平溢出的全宽/近全宽布局。
|
||||||
|
- 抽屉支持关闭按钮、遮罩点击、Escape 和焦点返回;打开后焦点进入抽屉。
|
||||||
|
- 没有未完成评估时仍可查看执行状态,抽屉显示空表状态,不创建无意义的空卡片。
|
||||||
|
|
||||||
|
### R2. 未完成评估表格
|
||||||
|
|
||||||
|
- 抽屉中的明细必须使用语义化 `table` 组件,不使用逐条卡片替代。
|
||||||
|
- 表格至少展示:股票名称、股票代码、评估状态、失败原因。
|
||||||
|
- 表头、单元格和行间距使用紧凑密度;长原因可换行,不撑破抽屉宽度。
|
||||||
|
- 表格提供 caption/可访问名称;无数据时显示可读的空状态。
|
||||||
|
- 表格属于 `selection` feature,不把策略字段或业务格式化下沉到 `shared/ui`。
|
||||||
|
|
||||||
|
### R3. 页面紧凑布局
|
||||||
|
|
||||||
|
- 收紧 `PageLayout` 的页面 gutter、操作区/指标区内边距和纵向间距;页面内容在桌面端尽量使用主区域宽度。
|
||||||
|
- 收紧选股页执行工具栏、指标区、筛选栏、详情面板、分页和各状态卡的 padding/margin/gap。
|
||||||
|
- 修复 bounded workspace 的连续高度链:操作区、指标区、筛选栏、分页不参与数据滚动,主表格数据区占满剩余高度并独立滚动。
|
||||||
|
- 保留移动端至少 `44px` 的主要触控命中区域;移动端改为自然页面滚动,不引入嵌套水平滚动。
|
||||||
|
- 不改变现有路由、筛选 URL 状态、分页行为、策略执行/轮询行为和主从选中行为。
|
||||||
|
|
||||||
|
### R4. 页面状态边界
|
||||||
|
|
||||||
|
- 请求加载、请求错误、无结果和执行失败等请求级状态仍可在主页面就地表达。
|
||||||
|
- 仅将“未完成评估”的结果明细从主页面迁移至抽屉,不把后端接口缺失伪装成前端数据。
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- 不新增或修改 FastAPI endpoint、Pydantic response model、数据库表或策略执行逻辑。
|
||||||
|
- 不实现行情数据、同步任务、全局搜索、个股研究详情或新的置信度接口。
|
||||||
|
- 不改变 `SelectionResults` API 的字段含义,不为“信号置信度”虚构数据。
|
||||||
|
- 不重写原型 HTML 的交互状态;原型继续作为视觉基线,正式页面以本任务的紧凑布局为准。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] 有失败结果时,主页面不出现“未完成评估”明细卡片;执行状态入口可见且可操作。
|
||||||
|
- [x] 点击执行状态后,右侧打开宽度为 `600px` 的抽屉;抽屉内存在可访问的 `table`,并正确展示所有失败记录。
|
||||||
|
- [x] 抽屉可以通过关闭按钮、遮罩、Escape 关闭,关闭后焦点返回执行状态入口。
|
||||||
|
- [x] 无失败记录时,执行状态入口仍能打开抽屉,表格显示空状态。
|
||||||
|
- [x] 1440×900 桌面视口下,选股工作台的数据面板填满操作区/指标区后的剩余高度,分页固定在面板底部,只有数据行区域滚动。
|
||||||
|
- [x] 390×844 与 375×812 移动视口下无页面级水平溢出,底部导航不遮挡可滚动内容,主要控件命中区域不低于 `44px`。
|
||||||
|
- [x] 前端格式、lint、类型检查、单元测试和构建通过;浏览器验证覆盖抽屉交互、紧凑布局和桌面/移动视口。
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
无。抽屉触发入口采用现有“执行状态”指标;本次不扩展后端接口。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "selection-status-drawer-compact-layout",
|
||||||
|
"name": "selection-status-drawer-compact-layout",
|
||||||
|
"title": "收紧选股执行状态交互与页面布局",
|
||||||
|
"description": "",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "yuxuanhui",
|
||||||
|
"assignee": "yuxuanhui",
|
||||||
|
"createdAt": "2026-08-10",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -24,12 +24,12 @@ export function PageLayout({
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
{actions ? (
|
{actions ? (
|
||||||
<div className="shrink-0 border-b border-border px-4 py-3 md:px-6 md:py-3">
|
<div className="shrink-0 border-b border-border px-4 py-2.5 md:px-4 md:py-2.5">
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{metrics ? (
|
{metrics ? (
|
||||||
<div className="shrink-0 border-b border-border px-4 py-3 md:px-6 md:py-3">
|
<div className="shrink-0 border-b border-border px-4 py-2.5 md:px-4 md:py-2.5">
|
||||||
{metrics}
|
{metrics}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -37,9 +37,9 @@ export function PageLayout({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 min-w-0 flex-1 flex-col",
|
"flex min-h-0 min-w-0 flex-1 flex-col",
|
||||||
bounded ? "overflow-y-auto md:overflow-hidden" : "overflow-y-auto",
|
bounded ? "overflow-y-auto md:overflow-hidden" : "overflow-y-auto",
|
||||||
"px-4 pb-6 pt-4 md:px-6 md:pb-8 md:pt-5",
|
"px-4 pb-6 pt-3 md:px-4 md:pb-6 md:pt-3",
|
||||||
// 移动端底部为底部导航预留安全区
|
// 移动端底部为底部导航预留安全区
|
||||||
"pb-[calc(5.25rem+env(safe-area-inset-bottom))] md:pb-8",
|
"pb-[calc(5.25rem+env(safe-area-inset-bottom))] md:pb-6",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
import type { RefObject } from "react"
|
||||||
|
|
||||||
|
import type { SelectionResults } from "../api/selection.types"
|
||||||
|
import { IncompleteEvaluationTable } from "./incomplete-evaluation-table"
|
||||||
|
import { getSelectionRunStatusPresentation } from "./selection-presentation"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/ui/dialog"
|
||||||
|
import { Button } from "@/shared/ui/button"
|
||||||
|
|
||||||
|
interface ExecutionStatusDrawerProps {
|
||||||
|
finalFocus: RefObject<HTMLElement | null>
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
open: boolean
|
||||||
|
result: SelectionResults
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecutionStatusDrawer({
|
||||||
|
finalFocus,
|
||||||
|
onOpenChange,
|
||||||
|
open,
|
||||||
|
result,
|
||||||
|
}: ExecutionStatusDrawerProps) {
|
||||||
|
const statusPresentation = getSelectionRunStatusPresentation(result.status)
|
||||||
|
const statusLabel = statusPresentation?.label ?? result.status
|
||||||
|
const tradeDate = result.target_trade_date ?? "未指定"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
aria-label="选股执行状态"
|
||||||
|
className="top-0 right-0 left-auto flex h-[100svh] min-h-[100svh] w-[min(600px,100vw)] max-w-none translate-x-full translate-y-0 flex-col grid-cols-1 gap-0 rounded-l-xl rounded-r-none border-y-0 border-r-0 p-4 data-open:translate-x-0 data-closed:translate-x-full sm:max-w-none"
|
||||||
|
finalFocus={finalFocus}
|
||||||
|
id="selection-execution-status-drawer"
|
||||||
|
showCloseButton={false}
|
||||||
|
>
|
||||||
|
<DialogHeader className="shrink-0 gap-1 pr-10">
|
||||||
|
<DialogTitle className="text-lg">知行 B1 执行状态</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
目标交易日:{tradeDate};当前状态:{statusLabel};未完成评估:
|
||||||
|
{result.failures.length} 条。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto py-4">
|
||||||
|
<IncompleteEvaluationTable failures={result.failures} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="shrink-0 -mx-4 -mb-4 rounded-none px-4 py-3">
|
||||||
|
<DialogClose render={<Button variant="outline" />}>关闭</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
|
||||||
|
<DialogClose
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
aria-label="关闭执行状态"
|
||||||
|
className="absolute top-3 right-3 size-11 md:size-8"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { SelectionFailure } from "../api/selection.types"
|
||||||
|
|
||||||
|
interface IncompleteEvaluationTableProps {
|
||||||
|
failures: SelectionFailure[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const failureStatusLabels: Record<string, string> = {
|
||||||
|
data_error: "数据读取失败",
|
||||||
|
insufficient_history: "历史数据不足",
|
||||||
|
missing_target_bar: "缺少目标交易日行情",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IncompleteEvaluationTable({
|
||||||
|
failures,
|
||||||
|
}: IncompleteEvaluationTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 overflow-hidden rounded-lg border border-border/70">
|
||||||
|
<table className="w-full table-fixed border-collapse text-left text-xs">
|
||||||
|
<caption className="sr-only">未完成评估明细</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-[22%]" />
|
||||||
|
<col className="w-[24%]" />
|
||||||
|
<col className="w-[22%]" />
|
||||||
|
<col className="w-[32%]" />
|
||||||
|
</colgroup>
|
||||||
|
<thead className="bg-muted/50 text-[11px] text-muted-foreground">
|
||||||
|
<tr className="border-b border-border/70">
|
||||||
|
<th className="px-2.5 py-2 font-medium" scope="col">
|
||||||
|
股票名称
|
||||||
|
</th>
|
||||||
|
<th className="px-2.5 py-2 font-medium" scope="col">
|
||||||
|
股票代码
|
||||||
|
</th>
|
||||||
|
<th className="px-2.5 py-2 font-medium" scope="col">
|
||||||
|
评估状态
|
||||||
|
</th>
|
||||||
|
<th className="px-2.5 py-2 font-medium" scope="col">
|
||||||
|
失败原因
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{failures.length > 0 ? (
|
||||||
|
failures.map((failure, index) => (
|
||||||
|
<tr
|
||||||
|
key={`${failure.ts_code}-${failure.status}-${index}`}
|
||||||
|
className="border-b border-border/60 last:border-b-0"
|
||||||
|
>
|
||||||
|
<td className="break-words px-2.5 py-2.5 align-top font-medium text-foreground">
|
||||||
|
{failure.name}
|
||||||
|
</td>
|
||||||
|
<td className="break-all px-2.5 py-2.5 align-top font-mono text-[11px] text-muted-foreground">
|
||||||
|
{failure.ts_code}
|
||||||
|
</td>
|
||||||
|
<td className="break-words px-2.5 py-2.5 align-top text-muted-foreground">
|
||||||
|
{formatFailureStatus(failure.status)}
|
||||||
|
</td>
|
||||||
|
<td className="break-words px-2.5 py-2.5 align-top leading-5 text-muted-foreground">
|
||||||
|
{formatFailureReason(failure.reason)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
className="px-3 py-10 text-center text-sm text-muted-foreground"
|
||||||
|
colSpan={4}
|
||||||
|
>
|
||||||
|
暂无未完成评估
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFailureStatus(status: string) {
|
||||||
|
return failureStatusLabels[status] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFailureReason(reason: string | null) {
|
||||||
|
return reason?.trim() || "—"
|
||||||
|
}
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
import type {
|
import type {
|
||||||
SelectionCategoryFilter,
|
SelectionCategoryFilter,
|
||||||
|
SelectionRunStatus,
|
||||||
SelectionSignal,
|
SelectionSignal,
|
||||||
} from "../api/selection.types"
|
} from "../api/selection.types"
|
||||||
|
|
||||||
|
export type SelectionRunStatusPresentation = {
|
||||||
|
label: string
|
||||||
|
variant: "default" | "destructive" | "outline"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectionRunStatusPresentation: Record<
|
||||||
|
Exclude<SelectionRunStatus, "no_data" | "running">,
|
||||||
|
SelectionRunStatusPresentation
|
||||||
|
> = {
|
||||||
|
failed: { label: "执行失败", variant: "destructive" },
|
||||||
|
partial_success: { label: "部分成功", variant: "outline" },
|
||||||
|
success: { label: "执行完成", variant: "default" },
|
||||||
|
}
|
||||||
|
|
||||||
export const categoryLabels: Record<string, string> = {
|
export const categoryLabels: Record<string, string> = {
|
||||||
zhixing_b1_extreme_volume: "超卖超缩量 B",
|
zhixing_b1_extreme_volume: "超卖超缩量 B",
|
||||||
zhixing_b1_original_b1: "原始 B1",
|
zhixing_b1_original_b1: "原始 B1",
|
||||||
@@ -29,6 +44,17 @@ export function getCategoryLabel(category: string) {
|
|||||||
return categoryLabels[category] ?? category
|
return categoryLabels[category] ?? category
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSelectionRunStatusPresentation(status: SelectionRunStatus) {
|
||||||
|
if (
|
||||||
|
status === "failed" ||
|
||||||
|
status === "partial_success" ||
|
||||||
|
status === "success"
|
||||||
|
) {
|
||||||
|
return selectionRunStatusPresentation[status]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function getSignalKey(signal: SelectionSignal) {
|
export function getSignalKey(signal: SelectionSignal) {
|
||||||
return `${signal.ts_code}-${signal.category}`
|
return `${signal.ts_code}-${signal.category}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ export function SelectionResultsWorkbench({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_260px] md:overflow-hidden lg:grid-cols-[minmax(0,1.55fr)_minmax(280px,0.72fr)]">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_260px] md:overflow-hidden lg:grid-cols-[minmax(0,1.55fr)_minmax(280px,0.72fr)]">
|
||||||
<section className="flex min-h-[420px] min-w-0 flex-1 flex-col overflow-visible rounded-lg border border-border/80 bg-card md:min-h-0 md:overflow-hidden">
|
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-visible rounded-lg border border-border/80 bg-card md:overflow-hidden">
|
||||||
<div className="grid shrink-0 gap-2 border-b border-border/60 p-3 sm:grid-cols-[minmax(0,260px)_auto_1fr] sm:items-center">
|
<div className="grid shrink-0 gap-1.5 border-b border-border/60 p-2.5 sm:grid-cols-[minmax(0,260px)_auto_1fr] sm:items-center">
|
||||||
<Input
|
<Input
|
||||||
aria-label="搜索命中信号"
|
aria-label="搜索命中信号"
|
||||||
className="h-11 bg-background text-sm md:h-8"
|
className="h-11 bg-background text-sm md:h-8"
|
||||||
@@ -120,7 +120,7 @@ export function SelectionResultsWorkbench({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-col md:min-h-0 md:flex-1 md:overflow-hidden">
|
<div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden">
|
||||||
<SignalTable
|
<SignalTable
|
||||||
onSelect={(signal) => setSelectedKey(getSignalKey(signal))}
|
onSelect={(signal) => setSelectedKey(getSignalKey(signal))}
|
||||||
selectedKey={selectedSignal ? getSignalKey(selectedSignal) : null}
|
selectedKey={selectedSignal ? getSignalKey(selectedSignal) : null}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
aria-label="当前股票详情"
|
aria-label="当前股票详情"
|
||||||
className="hidden min-h-0 overflow-auto rounded-lg border-border/80 bg-card p-4 md:flex md:flex-col md:justify-center"
|
className="hidden min-h-0 overflow-auto rounded-lg border-border/80 bg-card p-3 md:flex md:flex-col md:justify-center"
|
||||||
>
|
>
|
||||||
<div className="space-y-2 text-center">
|
<div className="space-y-2 text-center">
|
||||||
<Search
|
<Search
|
||||||
@@ -38,7 +38,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label="当前股票详情"
|
aria-label="当前股票详情"
|
||||||
className="hidden min-h-0 overflow-auto rounded-lg border border-border/80 bg-card p-4 md:block"
|
className="hidden min-h-0 overflow-auto rounded-lg border border-border/80 bg-card p-3 md:block"
|
||||||
>
|
>
|
||||||
<p className="text-[10px] font-bold tracking-[0.08em] text-muted-foreground uppercase">
|
<p className="text-[10px] font-bold tracking-[0.08em] text-muted-foreground uppercase">
|
||||||
Selected signal
|
Selected signal
|
||||||
@@ -58,7 +58,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-5 text-3xl font-semibold tabular-nums tracking-tight">
|
<p className="mt-4 text-3xl font-semibold tabular-nums tracking-tight">
|
||||||
¥ {signal.close.toFixed(2)}
|
¥ {signal.close.toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
|||||||
{getCategoryLabel(signal.category)}
|
{getCategoryLabel(signal.category)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<dl className="mt-5 divide-y divide-border/60 border-t border-border/60 text-xs">
|
<dl className="mt-4 divide-y divide-border/60 border-t border-border/60 text-xs">
|
||||||
<div className="flex items-center justify-between gap-3 py-2.5">
|
<div className="flex items-center justify-between gap-3 py-2.5">
|
||||||
<dt className="text-muted-foreground">目标交易日</dt>
|
<dt className="text-muted-foreground">目标交易日</dt>
|
||||||
<dd className="font-medium tabular-nums">
|
<dd className="font-medium tabular-nums">
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { fireEvent, render, screen, within } from "@testing-library/react"
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/react"
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
import type { SelectionResults } from "../api/selection.types"
|
import type { SelectionResults } from "../api/selection.types"
|
||||||
@@ -100,6 +106,77 @@ describe("SelectionResultsPage", () => {
|
|||||||
expect(within(table).getAllByText("平安银行")).toHaveLength(2)
|
expect(within(table).getAllByText("平安银行")).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("opens execution status details in a drawer and restores trigger focus", async () => {
|
||||||
|
useSelectionResults.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
...selectedResult,
|
||||||
|
failed_count: 1,
|
||||||
|
failures: [
|
||||||
|
{
|
||||||
|
name: "招商银行",
|
||||||
|
reason: "目标日数据读取失败",
|
||||||
|
status: "data_error",
|
||||||
|
ts_code: "600036.SH",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: "partial_success",
|
||||||
|
},
|
||||||
|
isError: false,
|
||||||
|
isPending: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
|
const trigger = screen.getByRole("button", {
|
||||||
|
name: "查看执行状态:部分成功,未完成评估 1 条",
|
||||||
|
})
|
||||||
|
fireEvent.click(trigger)
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("dialog", {
|
||||||
|
name: "知行 B1 执行状态",
|
||||||
|
})
|
||||||
|
const table = within(dialog).getByRole("table")
|
||||||
|
expect(
|
||||||
|
within(table).getByRole("columnheader", { name: "股票名称" }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(within(table).getByText("招商银行")).toBeInTheDocument()
|
||||||
|
expect(within(table).getByText("600036.SH")).toBeInTheDocument()
|
||||||
|
expect(within(table).getByText("数据读取失败")).toBeInTheDocument()
|
||||||
|
expect(within(table).getByText("目标日数据读取失败")).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "未完成评估" }),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
within(dialog).getByRole("button", { name: "关闭执行状态" }),
|
||||||
|
)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(document.activeElement).toBe(trigger)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows an empty evaluation table when there are no failures", async () => {
|
||||||
|
render(<SelectionResultsPage />)
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "查看执行状态:执行完成,未完成评估 0 条",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("dialog", {
|
||||||
|
name: "知行 B1 执行状态",
|
||||||
|
})
|
||||||
|
expect(within(dialog).getByRole("table")).toBeInTheDocument()
|
||||||
|
expect(within(dialog).getByText("暂无未完成评估")).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.keyDown(dialog, { key: "Escape" })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it("filters signals, updates the detail panel, and expands mobile details", () => {
|
it("filters signals, updates the detail panel, and expands mobile details", () => {
|
||||||
const extraSignal = {
|
const extraSignal = {
|
||||||
...selectedResult.signals[0],
|
...selectedResult.signals[0],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useSearch } from "@tanstack/react-router"
|
import { useSearch } from "@tanstack/react-router"
|
||||||
import { useState } from "react"
|
import { useRef, useState, type RefObject } from "react"
|
||||||
|
|
||||||
import { PageLayout } from "@/app/layout/page-layout"
|
import { PageLayout } from "@/app/layout/page-layout"
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
SelectionResults,
|
SelectionResults,
|
||||||
SelectionResultsQuery,
|
SelectionResultsQuery,
|
||||||
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"
|
||||||
import { Button } from "@/shared/ui/button"
|
import { Button } from "@/shared/ui/button"
|
||||||
@@ -39,23 +38,19 @@ import {
|
|||||||
import { Input } from "@/shared/ui/input"
|
import { Input } from "@/shared/ui/input"
|
||||||
import { Skeleton } from "@/shared/ui/skeleton"
|
import { Skeleton } from "@/shared/ui/skeleton"
|
||||||
|
|
||||||
|
import { ExecutionStatusDrawer } from "../components/execution-status-drawer"
|
||||||
|
import { getSelectionRunStatusPresentation } from "../components/selection-presentation"
|
||||||
import { SelectionResultsWorkbench } from "../components/selection-results-workbench"
|
import { SelectionResultsWorkbench } from "../components/selection-results-workbench"
|
||||||
|
|
||||||
const STRATEGY = "zhixing_b1" as const
|
const STRATEGY = "zhixing_b1" as const
|
||||||
|
|
||||||
const statusPresentation: Record<
|
|
||||||
Exclude<SelectionRunStatus, "no_data" | "running">,
|
|
||||||
{ label: string; variant: "default" | "destructive" | "outline" }
|
|
||||||
> = {
|
|
||||||
failed: { label: "执行失败", variant: "destructive" },
|
|
||||||
partial_success: { label: "部分成功", variant: "outline" },
|
|
||||||
success: { label: "执行完成", variant: "default" },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectionResultsPage() {
|
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 [executionStatusDrawerOpen, setExecutionStatusDrawerOpen] =
|
||||||
|
useState(false)
|
||||||
const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
|
const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
|
||||||
|
const executionStatusTriggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const search = useSearch({ from: "/_workspace/selection" })
|
const search = useSearch({ from: "/_workspace/selection" })
|
||||||
const resultQuery: SelectionResultsQuery = {
|
const resultQuery: SelectionResultsQuery = {
|
||||||
page: search.page,
|
page: search.page,
|
||||||
@@ -107,6 +102,7 @@ export function SelectionResultsPage() {
|
|||||||
function startExecution(rerun: boolean) {
|
function startExecution(rerun: boolean) {
|
||||||
if (!selectedTargetTradeDate) return
|
if (!selectedTargetTradeDate) return
|
||||||
setRerunDialogOpen(false)
|
setRerunDialogOpen(false)
|
||||||
|
setExecutionStatusDrawerOpen(false)
|
||||||
setActiveRunId(null)
|
setActiveRunId(null)
|
||||||
trigger.mutate(
|
trigger.mutate(
|
||||||
{
|
{
|
||||||
@@ -122,6 +118,7 @@ export function SelectionResultsPage() {
|
|||||||
|
|
||||||
function handleDateChange(value: string) {
|
function handleDateChange(value: string) {
|
||||||
setTargetTradeDate(value)
|
setTargetTradeDate(value)
|
||||||
|
setExecutionStatusDrawerOpen(false)
|
||||||
setActiveRunId(null)
|
setActiveRunId(null)
|
||||||
trigger.reset()
|
trigger.reset()
|
||||||
}
|
}
|
||||||
@@ -130,81 +127,99 @@ export function SelectionResultsPage() {
|
|||||||
displayedResult &&
|
displayedResult &&
|
||||||
displayedResult.status !== "no_data" &&
|
displayedResult.status !== "no_data" &&
|
||||||
displayedResult.status !== "running" ? (
|
displayedResult.status !== "running" ? (
|
||||||
<ResultMetrics result={displayedResult} />
|
<ResultMetrics
|
||||||
|
drawerOpen={executionStatusDrawerOpen}
|
||||||
|
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
|
||||||
|
result={displayedResult}
|
||||||
|
triggerRef={executionStatusTriggerRef}
|
||||||
|
/>
|
||||||
) : null
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<>
|
||||||
actions={
|
<PageLayout
|
||||||
<ExecutionToolbar
|
actions={
|
||||||
hasExistingResult={hasExistingResult}
|
<ExecutionToolbar
|
||||||
isFailedResult={displayedResult?.status === "failed"}
|
hasExistingResult={hasExistingResult}
|
||||||
isRunning={isRunning}
|
isFailedResult={displayedResult?.status === "failed"}
|
||||||
onDateChange={handleDateChange}
|
isRunning={isRunning}
|
||||||
onExecute={handleExecute}
|
onDateChange={handleDateChange}
|
||||||
selectedTargetTradeDate={selectedTargetTradeDate}
|
onExecute={handleExecute}
|
||||||
triggerError={trigger.isError}
|
selectedTargetTradeDate={selectedTargetTradeDate}
|
||||||
/>
|
triggerError={trigger.isError}
|
||||||
}
|
/>
|
||||||
metrics={resultMetrics}
|
}
|
||||||
mode="bounded-workspace"
|
metrics={resultMetrics}
|
||||||
>
|
mode="bounded-workspace"
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 md:overflow-y-auto">
|
>
|
||||||
{isRunning ? <RunningState /> : null}
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:overflow-hidden">
|
||||||
{!isRunning &&
|
{isRunning ? <RunningState /> : null}
|
||||||
!hasQueryError &&
|
{!isRunning &&
|
||||||
results.isPending &&
|
!hasQueryError &&
|
||||||
!displayedResult ? (
|
results.isPending &&
|
||||||
<LoadingState />
|
!displayedResult ? (
|
||||||
) : null}
|
<LoadingState />
|
||||||
{!isRunning && hasQueryError ? <QueryError /> : null}
|
) : null}
|
||||||
{!isRunning &&
|
{!isRunning && hasQueryError ? <QueryError /> : null}
|
||||||
!hasQueryError &&
|
{!isRunning &&
|
||||||
displayedResult?.status === "no_data" ? (
|
!hasQueryError &&
|
||||||
<NoDataState />
|
displayedResult?.status === "no_data" ? (
|
||||||
) : null}
|
<NoDataState />
|
||||||
{!isRunning &&
|
) : null}
|
||||||
!hasQueryError &&
|
{!isRunning &&
|
||||||
displayedResult?.status === "failed" ? (
|
!hasQueryError &&
|
||||||
<FailureState result={displayedResult} />
|
displayedResult?.status === "failed" ? (
|
||||||
) : null}
|
<FailureState result={displayedResult} />
|
||||||
{!isRunning &&
|
) : null}
|
||||||
!hasQueryError &&
|
{!isRunning &&
|
||||||
displayedResult &&
|
!hasQueryError &&
|
||||||
displayedResult.status !== "no_data" &&
|
displayedResult &&
|
||||||
displayedResult.status !== "failed" ? (
|
displayedResult.status !== "no_data" &&
|
||||||
<ResultState result={displayedResult} />
|
displayedResult.status !== "failed" ? (
|
||||||
) : null}
|
<ResultState result={displayedResult} />
|
||||||
</div>
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{rerunDialogOpen ? (
|
{rerunDialogOpen ? (
|
||||||
<Dialog open={rerunDialogOpen} onOpenChange={setRerunDialogOpen}>
|
<Dialog open={rerunDialogOpen} onOpenChange={setRerunDialogOpen}>
|
||||||
<DialogContent showCloseButton={false}>
|
<DialogContent showCloseButton={false}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>确认重新执行策略?</DialogTitle>
|
<DialogTitle>确认重新执行策略?</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
重新执行前会清空 {selectedTargetTradeDate} 的知行 B1
|
重新执行前会清空 {selectedTargetTradeDate} 的知行 B1
|
||||||
旧结果,再重新计算当前有效股票池。 旧结果清空后无法恢复。
|
旧结果,再重新计算当前有效股票池。 旧结果清空后无法恢复。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setRerunDialogOpen(false)}
|
onClick={() => setRerunDialogOpen(false)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => startExecution(true)}
|
onClick={() => startExecution(true)}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
>
|
>
|
||||||
确认重新执行
|
确认重新执行
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
) : null}
|
||||||
|
</PageLayout>
|
||||||
|
|
||||||
|
{displayedResult &&
|
||||||
|
displayedResult.status !== "no_data" &&
|
||||||
|
displayedResult.status !== "running" ? (
|
||||||
|
<ExecutionStatusDrawer
|
||||||
|
finalFocus={executionStatusTriggerRef}
|
||||||
|
onOpenChange={setExecutionStatusDrawerOpen}
|
||||||
|
open={executionStatusDrawerOpen}
|
||||||
|
result={displayedResult}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</PageLayout>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,8 +243,8 @@ function ExecutionToolbar({
|
|||||||
triggerError,
|
triggerError,
|
||||||
}: ExecutionToolbarProps) {
|
}: ExecutionToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-1.5">
|
||||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
|
<div className="grid gap-1.5 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
|
||||||
<label
|
<label
|
||||||
className="space-y-1 text-xs font-medium"
|
className="space-y-1 text-xs font-medium"
|
||||||
htmlFor="target-trade-date"
|
htmlFor="target-trade-date"
|
||||||
@@ -287,11 +302,21 @@ function ExecutionToolbar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResultMetrics({ result }: { result: SelectionResults }) {
|
function ResultMetrics({
|
||||||
const presentation = getStatusPresentation(result.status)
|
drawerOpen,
|
||||||
|
onOpenExecutionStatus,
|
||||||
|
result,
|
||||||
|
triggerRef,
|
||||||
|
}: {
|
||||||
|
drawerOpen: boolean
|
||||||
|
onOpenExecutionStatus: () => void
|
||||||
|
result: SelectionResults
|
||||||
|
triggerRef: RefObject<HTMLButtonElement | null>
|
||||||
|
}) {
|
||||||
|
const presentation = getSelectionRunStatusPresentation(result.status)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-full grid-cols-2 gap-x-5 gap-y-2 sm:grid-cols-3 lg:grid-cols-[repeat(5,minmax(0,1fr))_auto]">
|
<div className="grid w-full grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3 lg:grid-cols-[repeat(5,minmax(0,1fr))_auto]">
|
||||||
<Metric label="目标股票数" value={result.target_count} />
|
<Metric label="目标股票数" value={result.target_count} />
|
||||||
<Metric label="实际参与数" value={result.eligible_count} />
|
<Metric label="实际参与数" value={result.eligible_count} />
|
||||||
<Metric label="命中股票数" value={result.selected_stock_count} />
|
<Metric label="命中股票数" value={result.selected_stock_count} />
|
||||||
@@ -303,37 +328,40 @@ function ResultMetrics({ result }: { result: SelectionResults }) {
|
|||||||
{presentation ? (
|
{presentation ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-[11px] text-muted-foreground">执行状态</p>
|
<p className="text-[11px] text-muted-foreground">执行状态</p>
|
||||||
<Badge className="gap-1" variant={presentation.variant}>
|
<Button
|
||||||
{result.status === "success" ? (
|
aria-controls="selection-execution-status-drawer"
|
||||||
<CheckCircle2 aria-hidden="true" />
|
aria-expanded={drawerOpen}
|
||||||
) : null}
|
aria-haspopup="dialog"
|
||||||
{presentation.label}
|
aria-label={`查看执行状态:${presentation.label},未完成评估 ${result.failures.length} 条`}
|
||||||
</Badge>
|
className="h-auto min-h-11 justify-start gap-1.5 px-2 md:min-h-8"
|
||||||
|
onClick={onOpenExecutionStatus}
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Badge className="gap-1" variant={presentation.variant}>
|
||||||
|
{result.status === "success" ? (
|
||||||
|
<CheckCircle2 aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
{presentation.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{result.failures.length} 条未完成
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusPresentation(status: SelectionRunStatus) {
|
|
||||||
if (
|
|
||||||
status === "failed" ||
|
|
||||||
status === "partial_success" ||
|
|
||||||
status === "success"
|
|
||||||
) {
|
|
||||||
return statusPresentation[status]
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingState() {
|
function LoadingState() {
|
||||||
return (
|
return (
|
||||||
<Card aria-label="正在加载策略执行结果">
|
<Card aria-label="正在加载策略执行结果">
|
||||||
<CardHeader className="gap-3">
|
<CardHeader className="gap-2 p-4">
|
||||||
<Skeleton className="h-6 w-48" />
|
<Skeleton className="h-6 w-48" />
|
||||||
<Skeleton className="h-4 w-72 max-w-full" />
|
<Skeleton className="h-4 w-72 max-w-full" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-3 p-4 pt-0">
|
||||||
<Skeleton className="h-20 w-full" />
|
<Skeleton className="h-20 w-full" />
|
||||||
<Skeleton className="h-32 w-full" />
|
<Skeleton className="h-32 w-full" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -344,7 +372,7 @@ function LoadingState() {
|
|||||||
function RunningState() {
|
function RunningState() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="gap-1.5 p-4">
|
||||||
<CardTitle className="flex items-center gap-2 text-xl">
|
<CardTitle className="flex items-center gap-2 text-xl">
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -363,7 +391,7 @@ function RunningState() {
|
|||||||
function QueryError() {
|
function QueryError() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="gap-1.5 p-4">
|
||||||
<CardTitle className="flex items-center gap-2 text-xl">
|
<CardTitle className="flex items-center gap-2 text-xl">
|
||||||
<AlertTriangle
|
<AlertTriangle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -382,7 +410,7 @@ function QueryError() {
|
|||||||
function NoDataState() {
|
function NoDataState() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="gap-1.5 p-4">
|
||||||
<CardTitle className="flex items-center gap-2 text-xl">
|
<CardTitle className="flex items-center gap-2 text-xl">
|
||||||
<Search aria-hidden="true" className="size-5 text-primary" />
|
<Search aria-hidden="true" className="size-5 text-primary" />
|
||||||
暂无策略结果
|
暂无策略结果
|
||||||
@@ -398,7 +426,7 @@ function NoDataState() {
|
|||||||
function FailureState({ result }: { result: SelectionResults }) {
|
function FailureState({ result }: { result: SelectionResults }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="gap-1.5 p-4">
|
||||||
<CardTitle className="flex items-center gap-2 text-xl">
|
<CardTitle className="flex items-center gap-2 text-xl">
|
||||||
<AlertTriangle
|
<AlertTriangle
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -411,7 +439,7 @@ function FailureState({ result }: { result: SelectionResults }) {
|
|||||||
"本次执行没有形成可用的策略结果,请确认后重试。"}
|
"本次执行没有形成可用的策略结果,请确认后重试。"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
目标交易日:
|
目标交易日:
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
@@ -428,24 +456,17 @@ function FailureState({ result }: { result: SelectionResults }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ResultState({ result }: { result: SelectionResults }) {
|
function ResultState({ result }: { result: SelectionResults }) {
|
||||||
return (
|
return result.signal_count === 0 ? (
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
<NoSignalState />
|
||||||
{result.signal_count === 0 ? (
|
) : (
|
||||||
<NoSignalState />
|
<SelectionResultsWorkbench result={result} />
|
||||||
) : (
|
|
||||||
<div className="min-h-[420px] shrink-0">
|
|
||||||
<SelectionResultsWorkbench result={result} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{result.failures.length > 0 ? <FailureList result={result} /> : null}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoSignalState() {
|
function NoSignalState() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="gap-1.5 p-4">
|
||||||
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
目标交易日数据完整,但没有符合公式条件的股票。
|
目标交易日数据完整,但没有符合公式条件的股票。
|
||||||
@@ -455,42 +476,6 @@ function NoSignalState() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FailureList({ result }: { result: SelectionResults }) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-xl">未完成评估</CardTitle>
|
|
||||||
<CardDescription>这些股票未形成完整的策略评估结果。</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{result.failures.map((failure) => (
|
|
||||||
<div
|
|
||||||
key={`${failure.ts_code}-${failure.status}`}
|
|
||||||
className="rounded-lg border bg-muted/30 p-3"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{failure.name}</p>
|
|
||||||
<p className="font-mono text-xs text-muted-foreground">
|
|
||||||
{failure.ts_code}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{failure.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{failure.reason ? (
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
{failure.reason}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Metric({ label, value }: { label: string; value: number | string }) {
|
function Metric({ label, value }: { label: string; value: number | string }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function Pagination({
|
|||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
aria-label="表格分页"
|
aria-label="表格分页"
|
||||||
className="flex min-h-12 flex-wrap items-center gap-3 border-t border-border/60 px-3 py-2 text-xs text-muted-foreground"
|
className="flex min-h-12 shrink-0 flex-wrap items-center gap-2 border-t border-border/60 px-2.5 py-1.5 text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span className="tabular-nums" aria-live="polite">
|
<span className="tabular-nums" aria-live="polite">
|
||||||
{rangeLabel(currentPage, pageSize, total)}
|
{rangeLabel(currentPage, pageSize, total)}
|
||||||
|
|||||||
Reference in New Issue
Block a user