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

This commit is contained in:
yuxuanhui
2026-08-10 13:42:53 +08:00
parent abe4ffc408
commit 17237e01a7
15 changed files with 659 additions and 172 deletions
@@ -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 {
SelectionCategoryFilter,
SelectionRunStatus,
SelectionSignal,
} 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> = {
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}`
}
@@ -90,9 +90,9 @@ export function SelectionResultsWorkbench({
}
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)]">
<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">
<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="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-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-1.5 border-b border-border/60 p-2.5 sm:grid-cols-[minmax(0,260px)_auto_1fr] sm:items-center">
<Input
aria-label="搜索命中信号"
className="h-11 bg-background text-sm md:h-8"
@@ -120,7 +120,7 @@ export function SelectionResultsWorkbench({
</span>
</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
onSelect={(signal) => setSelectedKey(getSignalKey(signal))}
selectedKey={selectedSignal ? getSignalKey(selectedSignal) : null}
@@ -19,7 +19,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
return (
<Card
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">
<Search
@@ -38,7 +38,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
return (
<aside
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">
Selected signal
@@ -58,7 +58,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
/>
</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)}
</p>
@@ -69,7 +69,7 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
{getCategoryLabel(signal.category)}
</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">
<dt className="text-muted-foreground">目标交易日</dt>
<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 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(<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", () => {
const extraSignal = {
...selectedResult.signals[0],
@@ -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<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() {
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
const [activeRunId, setActiveRunId] = useState<string | null>(null)
const [executionStatusDrawerOpen, setExecutionStatusDrawerOpen] =
useState(false)
const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
const executionStatusTriggerRef = useRef<HTMLButtonElement>(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" ? (
<ResultMetrics result={displayedResult} />
<ResultMetrics
drawerOpen={executionStatusDrawerOpen}
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
result={displayedResult}
triggerRef={executionStatusTriggerRef}
/>
) : null
return (
<PageLayout
actions={
<ExecutionToolbar
hasExistingResult={hasExistingResult}
isFailedResult={displayedResult?.status === "failed"}
isRunning={isRunning}
onDateChange={handleDateChange}
onExecute={handleExecute}
selectedTargetTradeDate={selectedTargetTradeDate}
triggerError={trigger.isError}
/>
}
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}
{!isRunning &&
!hasQueryError &&
results.isPending &&
!displayedResult ? (
<LoadingState />
) : null}
{!isRunning && hasQueryError ? <QueryError /> : null}
{!isRunning &&
!hasQueryError &&
displayedResult?.status === "no_data" ? (
<NoDataState />
) : null}
{!isRunning &&
!hasQueryError &&
displayedResult?.status === "failed" ? (
<FailureState result={displayedResult} />
) : null}
{!isRunning &&
!hasQueryError &&
displayedResult &&
displayedResult.status !== "no_data" &&
displayedResult.status !== "failed" ? (
<ResultState result={displayedResult} />
) : null}
</div>
<>
<PageLayout
actions={
<ExecutionToolbar
hasExistingResult={hasExistingResult}
isFailedResult={displayedResult?.status === "failed"}
isRunning={isRunning}
onDateChange={handleDateChange}
onExecute={handleExecute}
selectedTargetTradeDate={selectedTargetTradeDate}
triggerError={trigger.isError}
/>
}
metrics={resultMetrics}
mode="bounded-workspace"
>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:overflow-hidden">
{isRunning ? <RunningState /> : null}
{!isRunning &&
!hasQueryError &&
results.isPending &&
!displayedResult ? (
<LoadingState />
) : null}
{!isRunning && hasQueryError ? <QueryError /> : null}
{!isRunning &&
!hasQueryError &&
displayedResult?.status === "no_data" ? (
<NoDataState />
) : null}
{!isRunning &&
!hasQueryError &&
displayedResult?.status === "failed" ? (
<FailureState result={displayedResult} />
) : null}
{!isRunning &&
!hasQueryError &&
displayedResult &&
displayedResult.status !== "no_data" &&
displayedResult.status !== "failed" ? (
<ResultState result={displayedResult} />
) : null}
</div>
{rerunDialogOpen ? (
<Dialog open={rerunDialogOpen} onOpenChange={setRerunDialogOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>确认重新执行策略?</DialogTitle>
<DialogDescription>
重新执行前会清空 {selectedTargetTradeDate} 的知行 B1
旧结果,再重新计算当前有效股票池。 旧结果清空后无法恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={() => setRerunDialogOpen(false)}
variant="outline"
>
取消
</Button>
<Button
onClick={() => startExecution(true)}
variant="destructive"
>
确认重新执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{rerunDialogOpen ? (
<Dialog open={rerunDialogOpen} onOpenChange={setRerunDialogOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>确认重新执行策略?</DialogTitle>
<DialogDescription>
重新执行前会清空 {selectedTargetTradeDate} 的知行 B1
旧结果,再重新计算当前有效股票池。 旧结果清空后无法恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={() => setRerunDialogOpen(false)}
variant="outline"
>
取消
</Button>
<Button
onClick={() => startExecution(true)}
variant="destructive"
>
确认重新执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</PageLayout>
{displayedResult &&
displayedResult.status !== "no_data" &&
displayedResult.status !== "running" ? (
<ExecutionStatusDrawer
finalFocus={executionStatusTriggerRef}
onOpenChange={setExecutionStatusDrawerOpen}
open={executionStatusDrawerOpen}
result={displayedResult}
/>
) : null}
</PageLayout>
</>
)
}
@@ -228,8 +243,8 @@ function ExecutionToolbar({
triggerError,
}: ExecutionToolbarProps) {
return (
<div className="w-full space-y-2">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
<div className="w-full space-y-1.5">
<div className="grid gap-1.5 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
<label
className="space-y-1 text-xs font-medium"
htmlFor="target-trade-date"
@@ -287,11 +302,21 @@ function ExecutionToolbar({
)
}
function ResultMetrics({ result }: { result: SelectionResults }) {
const presentation = getStatusPresentation(result.status)
function ResultMetrics({
drawerOpen,
onOpenExecutionStatus,
result,
triggerRef,
}: {
drawerOpen: boolean
onOpenExecutionStatus: () => void
result: SelectionResults
triggerRef: RefObject<HTMLButtonElement | null>
}) {
const presentation = getSelectionRunStatusPresentation(result.status)
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.eligible_count} />
<Metric label="命中股票数" value={result.selected_stock_count} />
@@ -303,37 +328,40 @@ function ResultMetrics({ result }: { result: SelectionResults }) {
{presentation ? (
<div className="space-y-1">
<p className="text-[11px] text-muted-foreground">执行状态</p>
<Badge className="gap-1" variant={presentation.variant}>
{result.status === "success" ? (
<CheckCircle2 aria-hidden="true" />
) : null}
{presentation.label}
</Badge>
<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}>
{result.status === "success" ? (
<CheckCircle2 aria-hidden="true" />
) : null}
{presentation.label}
</Badge>
<span className="text-[11px] text-muted-foreground">
{result.failures.length} 条未完成
</span>
</Button>
</div>
) : null}
</div>
)
}
function getStatusPresentation(status: SelectionRunStatus) {
if (
status === "failed" ||
status === "partial_success" ||
status === "success"
) {
return statusPresentation[status]
}
return null
}
function LoadingState() {
return (
<Card aria-label="正在加载策略执行结果">
<CardHeader className="gap-3">
<CardHeader className="gap-2 p-4">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72 max-w-full" />
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3 p-4 pt-0">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
@@ -344,7 +372,7 @@ function LoadingState() {
function RunningState() {
return (
<Card>
<CardHeader>
<CardHeader className="gap-1.5 p-4">
<CardTitle className="flex items-center gap-2 text-xl">
<RefreshCw
aria-hidden="true"
@@ -363,7 +391,7 @@ function RunningState() {
function QueryError() {
return (
<Card>
<CardHeader>
<CardHeader className="gap-1.5 p-4">
<CardTitle className="flex items-center gap-2 text-xl">
<AlertTriangle
aria-hidden="true"
@@ -382,7 +410,7 @@ function QueryError() {
function NoDataState() {
return (
<Card>
<CardHeader>
<CardHeader className="gap-1.5 p-4">
<CardTitle className="flex items-center gap-2 text-xl">
<Search aria-hidden="true" className="size-5 text-primary" />
暂无策略结果
@@ -398,7 +426,7 @@ function NoDataState() {
function FailureState({ result }: { result: SelectionResults }) {
return (
<Card>
<CardHeader>
<CardHeader className="gap-1.5 p-4">
<CardTitle className="flex items-center gap-2 text-xl">
<AlertTriangle
aria-hidden="true"
@@ -411,7 +439,7 @@ function FailureState({ result }: { result: SelectionResults }) {
"本次执行没有形成可用的策略结果,请确认后重试。"}
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="p-4 pt-0">
<p className="text-sm text-muted-foreground">
目标交易日:
<span className="font-medium text-foreground">
@@ -428,24 +456,17 @@ function FailureState({ result }: { result: SelectionResults }) {
}
function ResultState({ result }: { result: SelectionResults }) {
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
{result.signal_count === 0 ? (
<NoSignalState />
) : (
<div className="min-h-[420px] shrink-0">
<SelectionResultsWorkbench result={result} />
</div>
)}
{result.failures.length > 0 ? <FailureList result={result} /> : null}
</div>
return result.signal_count === 0 ? (
<NoSignalState />
) : (
<SelectionResultsWorkbench result={result} />
)
}
function NoSignalState() {
return (
<Card>
<CardHeader>
<CardHeader className="gap-1.5 p-4">
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
<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 }) {
return (
<div className="space-y-0.5">