diff --git a/.trellis/tasks/08-10-selection-status-drawer-compact-layout/check.jsonl b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/check.jsonl new file mode 100644 index 0000000..c0eb7cb --- /dev/null +++ b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/check.jsonl @@ -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、类型、测试、构建和审查门禁。"} diff --git a/.trellis/tasks/08-10-selection-status-drawer-compact-layout/design.md b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/design.md new file mode 100644 index 0000000..ebfe2ec --- /dev/null +++ b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/design.md @@ -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;检查抽屉宽度、分页底部位置、数据区滚动和无水平溢出。 diff --git a/.trellis/tasks/08-10-selection-status-drawer-compact-layout/implement.jsonl b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/implement.jsonl new file mode 100644 index 0000000..96431ab --- /dev/null +++ b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/implement.jsonl @@ -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、类型、测试和构建门禁。"} diff --git a/.trellis/tasks/08-10-selection-status-drawer-compact-layout/implement.md b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/implement.md new file mode 100644 index 0000000..5271f7d --- /dev/null +++ b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/implement.md @@ -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`。 diff --git a/.trellis/tasks/08-10-selection-status-drawer-compact-layout/prd.md b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/prd.md new file mode 100644 index 0000000..a98c0a9 --- /dev/null +++ b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/prd.md @@ -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 + +无。抽屉触发入口采用现有“执行状态”指标;本次不扩展后端接口。 diff --git a/.trellis/tasks/08-10-selection-status-drawer-compact-layout/task.json b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/task.json new file mode 100644 index 0000000..ebdaa07 --- /dev/null +++ b/.trellis/tasks/08-10-selection-status-drawer-compact-layout/task.json @@ -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": {} +} \ No newline at end of file diff --git a/zhixing-web/src/app/layout/page-layout.tsx b/zhixing-web/src/app/layout/page-layout.tsx index b780c97..a0e2049 100644 --- a/zhixing-web/src/app/layout/page-layout.tsx +++ b/zhixing-web/src/app/layout/page-layout.tsx @@ -24,12 +24,12 @@ export function PageLayout({ return (
{actions ? ( -
+
{actions}
) : null} {metrics ? ( -
+
{metrics}
) : null} @@ -37,9 +37,9 @@ export function PageLayout({ className={cn( "flex min-h-0 min-w-0 flex-1 flex-col", 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} diff --git a/zhixing-web/src/features/selection/components/execution-status-drawer.tsx b/zhixing-web/src/features/selection/components/execution-status-drawer.tsx new file mode 100644 index 0000000..545ec58 --- /dev/null +++ b/zhixing-web/src/features/selection/components/execution-status-drawer.tsx @@ -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 + 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 ( + + + + 知行 B1 执行状态 + + 目标交易日:{tradeDate};当前状态:{statusLabel};未完成评估: + {result.failures.length} 条。 + + + +
+ +
+ + + }>关闭 + + + + } + > + +
+
+ ) +} diff --git a/zhixing-web/src/features/selection/components/incomplete-evaluation-table.tsx b/zhixing-web/src/features/selection/components/incomplete-evaluation-table.tsx new file mode 100644 index 0000000..0260b25 --- /dev/null +++ b/zhixing-web/src/features/selection/components/incomplete-evaluation-table.tsx @@ -0,0 +1,85 @@ +import type { SelectionFailure } from "../api/selection.types" + +interface IncompleteEvaluationTableProps { + failures: SelectionFailure[] +} + +const failureStatusLabels: Record = { + data_error: "数据读取失败", + insufficient_history: "历史数据不足", + missing_target_bar: "缺少目标交易日行情", +} + +export function IncompleteEvaluationTable({ + failures, +}: IncompleteEvaluationTableProps) { + return ( +
+ + + + + + + + + + + + + + + + + + {failures.length > 0 ? ( + failures.map((failure, index) => ( + + + + + + + )) + ) : ( + + + + )} + +
未完成评估明细
+ 股票名称 + + 股票代码 + + 评估状态 + + 失败原因 +
+ {failure.name} + + {failure.ts_code} + + {formatFailureStatus(failure.status)} + + {formatFailureReason(failure.reason)} +
+ 暂无未完成评估 +
+
+ ) +} + +function formatFailureStatus(status: string) { + return failureStatusLabels[status] ?? status +} + +function formatFailureReason(reason: string | null) { + return reason?.trim() || "—" +} diff --git a/zhixing-web/src/features/selection/components/selection-presentation.ts b/zhixing-web/src/features/selection/components/selection-presentation.ts index 7a21a70..9fb3bd2 100644 --- a/zhixing-web/src/features/selection/components/selection-presentation.ts +++ b/zhixing-web/src/features/selection/components/selection-presentation.ts @@ -1,8 +1,23 @@ import type { SelectionCategoryFilter, + SelectionRunStatus, SelectionSignal, } from "../api/selection.types" +export type SelectionRunStatusPresentation = { + label: string + variant: "default" | "destructive" | "outline" +} + +export const selectionRunStatusPresentation: Record< + Exclude, + SelectionRunStatusPresentation +> = { + failed: { label: "执行失败", variant: "destructive" }, + partial_success: { label: "部分成功", variant: "outline" }, + success: { label: "执行完成", variant: "default" }, +} + export const categoryLabels: Record = { zhixing_b1_extreme_volume: "超卖超缩量 B", zhixing_b1_original_b1: "原始 B1", @@ -29,6 +44,17 @@ export function getCategoryLabel(category: string) { 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) { return `${signal.ts_code}-${signal.category}` } diff --git a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx index a98ecf4..7a0a7a6 100644 --- a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx +++ b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx @@ -90,9 +90,9 @@ export function SelectionResultsWorkbench({ } return ( -
-
-
+
+
+
-
+
setSelectedKey(getSignalKey(signal))} selectedKey={selectedSignal ? getSignalKey(selectedSignal) : null} diff --git a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx index 8f9781d..635de96 100644 --- a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx +++ b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx @@ -19,7 +19,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) { return (

Selected signal @@ -58,7 +58,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) { />

-

+

¥ {signal.close.toFixed(2)}

@@ -69,7 +69,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) { {getCategoryLabel(signal.category)} -
+
目标交易日
diff --git a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx index be3d41b..717837a 100644 --- a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx +++ b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx @@ -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 type { SelectionResults } from "../api/selection.types" @@ -100,6 +106,77 @@ describe("SelectionResultsPage", () => { 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() + + 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() + + 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", () => { const extraSignal = { ...selectedResult.signals[0], diff --git a/zhixing-web/src/features/selection/pages/selection-results-page.tsx b/zhixing-web/src/features/selection/pages/selection-results-page.tsx index 6e61297..e992ab9 100644 --- a/zhixing-web/src/features/selection/pages/selection-results-page.tsx +++ b/zhixing-web/src/features/selection/pages/selection-results-page.tsx @@ -6,7 +6,7 @@ import { Search, } from "lucide-react" 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 { @@ -17,7 +17,6 @@ import { import type { SelectionResults, SelectionResultsQuery, - SelectionRunStatus, } from "@/features/selection/api/selection.types" import { Badge } from "@/shared/ui/badge" import { Button } from "@/shared/ui/button" @@ -39,23 +38,19 @@ import { import { Input } from "@/shared/ui/input" 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" const STRATEGY = "zhixing_b1" as const -const statusPresentation: Record< - Exclude, - { label: string; variant: "default" | "destructive" | "outline" } -> = { - failed: { label: "执行失败", variant: "destructive" }, - partial_success: { label: "部分成功", variant: "outline" }, - success: { label: "执行完成", variant: "default" }, -} - export function SelectionResultsPage() { const [targetTradeDate, setTargetTradeDate] = useState(null) const [activeRunId, setActiveRunId] = useState(null) + const [executionStatusDrawerOpen, setExecutionStatusDrawerOpen] = + useState(false) const [rerunDialogOpen, setRerunDialogOpen] = useState(false) + const executionStatusTriggerRef = useRef(null) const search = useSearch({ from: "/_workspace/selection" }) const resultQuery: SelectionResultsQuery = { page: search.page, @@ -107,6 +102,7 @@ export function SelectionResultsPage() { function startExecution(rerun: boolean) { if (!selectedTargetTradeDate) return setRerunDialogOpen(false) + setExecutionStatusDrawerOpen(false) setActiveRunId(null) trigger.mutate( { @@ -122,6 +118,7 @@ export function SelectionResultsPage() { function handleDateChange(value: string) { setTargetTradeDate(value) + setExecutionStatusDrawerOpen(false) setActiveRunId(null) trigger.reset() } @@ -130,81 +127,99 @@ export function SelectionResultsPage() { displayedResult && displayedResult.status !== "no_data" && displayedResult.status !== "running" ? ( - + setExecutionStatusDrawerOpen(true)} + result={displayedResult} + triggerRef={executionStatusTriggerRef} + /> ) : null return ( - - } - metrics={resultMetrics} - mode="bounded-workspace" - > -
- {isRunning ? : null} - {!isRunning && - !hasQueryError && - results.isPending && - !displayedResult ? ( - - ) : null} - {!isRunning && hasQueryError ? : null} - {!isRunning && - !hasQueryError && - displayedResult?.status === "no_data" ? ( - - ) : null} - {!isRunning && - !hasQueryError && - displayedResult?.status === "failed" ? ( - - ) : null} - {!isRunning && - !hasQueryError && - displayedResult && - displayedResult.status !== "no_data" && - displayedResult.status !== "failed" ? ( - - ) : null} -
+ <> + + } + metrics={resultMetrics} + mode="bounded-workspace" + > +
+ {isRunning ? : null} + {!isRunning && + !hasQueryError && + results.isPending && + !displayedResult ? ( + + ) : null} + {!isRunning && hasQueryError ? : null} + {!isRunning && + !hasQueryError && + displayedResult?.status === "no_data" ? ( + + ) : null} + {!isRunning && + !hasQueryError && + displayedResult?.status === "failed" ? ( + + ) : null} + {!isRunning && + !hasQueryError && + displayedResult && + displayedResult.status !== "no_data" && + displayedResult.status !== "failed" ? ( + + ) : null} +
- {rerunDialogOpen ? ( - - - - 确认重新执行策略? - - 重新执行前会清空 {selectedTargetTradeDate} 的知行 B1 - 旧结果,再重新计算当前有效股票池。 旧结果清空后无法恢复。 - - - - - - - - + {rerunDialogOpen ? ( + + + + 确认重新执行策略? + + 重新执行前会清空 {selectedTargetTradeDate} 的知行 B1 + 旧结果,再重新计算当前有效股票池。 旧结果清空后无法恢复。 + + + + + + + + + ) : null} +
+ + {displayedResult && + displayedResult.status !== "no_data" && + displayedResult.status !== "running" ? ( + ) : null} -
+ ) } @@ -228,8 +243,8 @@ function ExecutionToolbar({ triggerError, }: ExecutionToolbarProps) { return ( -
-
+
+