feat(selection): 收紧执行状态交互与页面布局
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user