feat(selection): 收紧执行状态交互与页面布局 #8

Merged
sakibcc merged 1 commits from develop into main 2026-08-10 13:43:51 +08:00
15 changed files with 659 additions and 172 deletions
@@ -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": {}
}
+4 -4
View File
@@ -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,10 +127,16 @@ 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 <PageLayout
actions={ actions={
<ExecutionToolbar <ExecutionToolbar
@@ -149,7 +152,7 @@ export function SelectionResultsPage() {
metrics={resultMetrics} metrics={resultMetrics}
mode="bounded-workspace" mode="bounded-workspace"
> >
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 md:overflow-y-auto"> <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:overflow-hidden">
{isRunning ? <RunningState /> : null} {isRunning ? <RunningState /> : null}
{!isRunning && {!isRunning &&
!hasQueryError && !hasQueryError &&
@@ -205,6 +208,18 @@ export function SelectionResultsPage() {
</Dialog> </Dialog>
) : null} ) : null}
</PageLayout> </PageLayout>
{displayedResult &&
displayedResult.status !== "no_data" &&
displayedResult.status !== "running" ? (
<ExecutionStatusDrawer
finalFocus={executionStatusTriggerRef}
onOpenChange={setExecutionStatusDrawerOpen}
open={executionStatusDrawerOpen}
result={displayedResult}
/>
) : null}
</>
) )
} }
@@ -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>
<Button
aria-controls="selection-execution-status-drawer"
aria-expanded={drawerOpen}
aria-haspopup="dialog"
aria-label={`查看执行状态:${presentation.label},未完成评估 ${result.failures.length} 条`}
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}> <Badge className="gap-1" variant={presentation.variant}>
{result.status === "success" ? ( {result.status === "success" ? (
<CheckCircle2 aria-hidden="true" /> <CheckCircle2 aria-hidden="true" />
) : null} ) : null}
{presentation.label} {presentation.label}
</Badge> </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">
{result.signal_count === 0 ? (
<NoSignalState /> <NoSignalState />
) : ( ) : (
<div className="min-h-[420px] shrink-0">
<SelectionResultsWorkbench result={result} /> <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">
+1 -1
View File
@@ -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)}