feat(selection): 重构选股结果筛选与状态入口
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
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 { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -13,7 +12,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
import type { SelectionResults } from "../api/selection.types"
|
||||
import { IncompleteEvaluationTable } from "./incomplete-evaluation-table"
|
||||
import { getSelectionRunStatusPresentation } from "./selection-presentation"
|
||||
|
||||
interface ExecutionStatusDrawerProps {
|
||||
finalFocus: RefObject<HTMLElement | null>
|
||||
@@ -50,6 +52,37 @@ export function ExecutionStatusDrawer({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto py-4">
|
||||
<section
|
||||
aria-label="执行摘要"
|
||||
className="mb-4 rounded-lg border border-border/70 bg-muted/20 p-3"
|
||||
>
|
||||
<h2 className="sr-only">执行摘要</h2>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-3">
|
||||
<SummaryMetric label="目标股票数" value={result.target_count} />
|
||||
<SummaryMetric label="实际参与数" value={result.eligible_count} />
|
||||
<SummaryMetric
|
||||
label="命中股票数"
|
||||
value={result.selected_stock_count}
|
||||
/>
|
||||
<SummaryMetric label="信号条数" value={result.signal_count} />
|
||||
<SummaryMetric
|
||||
label="数据覆盖率"
|
||||
value={`${(result.coverage * 100).toFixed(2)}%`}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<dt className="text-[11px] text-muted-foreground">执行状态</dt>
|
||||
<dd>
|
||||
{statusPresentation ? (
|
||||
<Badge variant={statusPresentation.variant}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm font-semibold">{statusLabel}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
<IncompleteEvaluationTable failures={result.failures} />
|
||||
</div>
|
||||
|
||||
@@ -73,3 +106,20 @@ export function ExecutionStatusDrawer({
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryMetric({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<dt className="text-[11px] text-muted-foreground">{label}</dt>
|
||||
<dd className="text-base font-semibold tracking-tight tabular-nums">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
import type { RefObject } from "react"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
import type { SelectionResults } from "../api/selection.types"
|
||||
import { getSelectionRunStatusPresentation } from "./selection-presentation"
|
||||
|
||||
interface ExecutionStatusTriggerProps {
|
||||
drawerOpen: boolean
|
||||
onOpen: () => void
|
||||
result: SelectionResults
|
||||
triggerRef: RefObject<HTMLButtonElement | null>
|
||||
}
|
||||
|
||||
export function ExecutionStatusTrigger({
|
||||
drawerOpen,
|
||||
onOpen,
|
||||
result,
|
||||
triggerRef,
|
||||
}: ExecutionStatusTriggerProps) {
|
||||
const statusPresentation = getSelectionRunStatusPresentation(result.status)
|
||||
|
||||
if (!statusPresentation) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-controls="selection-execution-status-drawer"
|
||||
aria-expanded={drawerOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={`查看执行状态:${statusPresentation.label},未完成评估 ${result.failures.length} 条`}
|
||||
className="h-auto min-h-11 shrink-0 justify-start gap-1.5 px-2 md:min-h-8"
|
||||
onClick={onOpen}
|
||||
ref={triggerRef}
|
||||
variant="outline"
|
||||
>
|
||||
<Badge className="gap-1" variant={statusPresentation.variant}>
|
||||
{result.status === "success" ? (
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
) : null}
|
||||
{statusPresentation.label}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{result.failures.length} 条未完成
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState, type RefObject } from "react"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { Pagination } from "@/shared/ui/pagination"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select"
|
||||
|
||||
import type { SelectionResults, SelectionSignal } from "../api/selection.types"
|
||||
import { ExecutionStatusTrigger } from "./execution-status-trigger"
|
||||
import { SignalDetailPanel } from "./signal-detail-panel"
|
||||
import { SignalRecordList } from "./signal-record-list"
|
||||
import {
|
||||
@@ -18,11 +27,17 @@ import { SignalTable } from "./signal-table"
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
|
||||
|
||||
interface SelectionResultsWorkbenchProps {
|
||||
drawerOpen: boolean
|
||||
onOpenExecutionStatus: () => void
|
||||
result: SelectionResults
|
||||
triggerRef: RefObject<HTMLButtonElement | null>
|
||||
}
|
||||
|
||||
export function SelectionResultsWorkbench({
|
||||
drawerOpen,
|
||||
onOpenExecutionStatus,
|
||||
result,
|
||||
triggerRef,
|
||||
}: SelectionResultsWorkbenchProps) {
|
||||
const search = useSearch({ from: "/_workspace/selection" })
|
||||
const navigate = useNavigate({ from: "/selection" })
|
||||
@@ -53,7 +68,6 @@ export function SelectionResultsWorkbench({
|
||||
const selectedSignal =
|
||||
currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ??
|
||||
currentSignals[0]
|
||||
|
||||
useEffect(() => {
|
||||
if (page > pageCount) {
|
||||
void navigate({
|
||||
@@ -92,32 +106,49 @@ export function SelectionResultsWorkbench({
|
||||
return (
|
||||
<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">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
|
||||
<Input
|
||||
aria-label="搜索命中信号"
|
||||
className="h-11 bg-background text-sm md:h-8"
|
||||
className="h-11 min-w-0 flex-1 bg-background text-sm sm:max-w-[260px] md:h-8"
|
||||
onChange={(event) => handleQueryChange(event.target.value)}
|
||||
placeholder="搜索股票名称或代码"
|
||||
type="search"
|
||||
value={query}
|
||||
/>
|
||||
<select
|
||||
aria-label="筛选信号分类"
|
||||
className="h-11 rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:h-8"
|
||||
onChange={(event) =>
|
||||
handleCategoryChange(event.target.value as SignalCategoryFilter)
|
||||
}
|
||||
<Select
|
||||
items={signalCategoryOptions}
|
||||
onValueChange={(value) => {
|
||||
if (typeof value === "string") {
|
||||
handleCategoryChange(value as SignalCategoryFilter)
|
||||
}
|
||||
}}
|
||||
value={category}
|
||||
>
|
||||
{signalCategoryOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs tabular-nums text-muted-foreground sm:justify-self-end">
|
||||
<SelectTrigger
|
||||
aria-label="筛选信号分类"
|
||||
className="h-11 w-full bg-background text-sm sm:w-auto sm:min-w-36 md:h-8"
|
||||
>
|
||||
<SelectValue placeholder="筛选信号分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{signalCategoryOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs tabular-nums text-muted-foreground sm:ml-auto">
|
||||
筛选结果 {signalsTotal} 条
|
||||
</span>
|
||||
<ExecutionStatusTrigger
|
||||
drawerOpen={drawerOpen}
|
||||
onOpen={onOpenExecutionStatus}
|
||||
result={result}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden">
|
||||
|
||||
@@ -99,6 +99,7 @@ describe("SelectionResultsPage", () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByText("执行完成")).toBeInTheDocument()
|
||||
expect(screen.queryByText("目标股票数")).not.toBeInTheDocument()
|
||||
const table = screen.getByRole("table")
|
||||
expect(table).toBeInTheDocument()
|
||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
||||
@@ -130,11 +131,25 @@ describe("SelectionResultsPage", () => {
|
||||
const trigger = screen.getByRole("button", {
|
||||
name: "查看执行状态:部分成功,未完成评估 1 条",
|
||||
})
|
||||
expect(trigger).toHaveAttribute(
|
||||
"aria-controls",
|
||||
"selection-execution-status-drawer",
|
||||
)
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "知行 B1 执行状态",
|
||||
})
|
||||
const summary = within(dialog).getByRole("region", { name: "执行摘要" })
|
||||
expect(within(summary).getByText("目标股票数")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("实际参与数")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("命中股票数")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("信号条数")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("数据覆盖率")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("100.00%")).toBeInTheDocument()
|
||||
expect(within(summary).getByText("部分成功")).toBeInTheDocument()
|
||||
const table = within(dialog).getByRole("table")
|
||||
expect(
|
||||
within(table).getByRole("columnheader", { name: "股票名称" }),
|
||||
@@ -153,6 +168,7 @@ describe("SelectionResultsPage", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||
})
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
@@ -175,6 +191,111 @@ describe("SelectionResultsPage", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||
})
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "查看执行状态:执行完成,未完成评估 0 条",
|
||||
}),
|
||||
).toHaveFocus()
|
||||
})
|
||||
|
||||
it("keeps execution status accessible when no signals are hit", async () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
signal_count: 0,
|
||||
signals: [],
|
||||
signals_total: 0,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
const trigger = screen.getByRole("button", {
|
||||
name: "查看执行状态:执行完成,未完成评估 0 条",
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "知行 B1 执行状态",
|
||||
})
|
||||
expect(within(dialog).getByText("信号条数")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("keeps execution status accessible for a failed result", async () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
failed_count: 2,
|
||||
failures: [
|
||||
{
|
||||
name: "招商银行",
|
||||
reason: "目标日数据读取失败",
|
||||
status: "data_error",
|
||||
ts_code: "600036.SZ",
|
||||
},
|
||||
{
|
||||
name: "宁德时代",
|
||||
reason: "指标计算失败",
|
||||
status: "calculation_error",
|
||||
ts_code: "300750.SZ",
|
||||
},
|
||||
],
|
||||
signal_count: 0,
|
||||
signals: [],
|
||||
status: "failed",
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "查看执行状态:执行失败,未完成评估 2 条",
|
||||
}),
|
||||
)
|
||||
const dialog = await screen.findByRole("dialog", {
|
||||
name: "知行 B1 执行状态",
|
||||
})
|
||||
expect(within(dialog).getByText("执行失败")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("uses shared selects and resets pagination when the category changes", async () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "策略" })).toHaveTextContent(
|
||||
"知行 B1",
|
||||
)
|
||||
const categoryTrigger = screen.getByRole("combobox", {
|
||||
name: "筛选信号分类",
|
||||
})
|
||||
expect(categoryTrigger).toHaveTextContent("全部信号")
|
||||
|
||||
fireEvent.click(categoryTrigger)
|
||||
const oversoldOption = await screen.findByRole("option", { name: "超卖类" })
|
||||
fireEvent.pointerDown(oversoldOption, { pointerType: "mouse" })
|
||||
fireEvent.click(oversoldOption)
|
||||
|
||||
expect(routerNavigate).toHaveBeenCalled()
|
||||
const lastCall =
|
||||
routerNavigate.mock.calls[routerNavigate.mock.calls.length - 1]
|
||||
const searchUpdate = lastCall[0].search as (previous: {
|
||||
category: string
|
||||
page: number
|
||||
pageSize: number
|
||||
search: string
|
||||
}) => Record<string, unknown>
|
||||
expect(
|
||||
searchUpdate({ category: "all", page: 4, pageSize: 5, search: "" }),
|
||||
).toEqual({
|
||||
category: "oversold",
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
search: "",
|
||||
})
|
||||
})
|
||||
|
||||
it("filters signals, updates the detail panel, and expands mobile details", () => {
|
||||
@@ -298,14 +419,14 @@ describe("SelectionResultsPage", () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders a no-data state and triggers an initial run after date selection", () => {
|
||||
it("renders a no-data state and triggers an initial run after date selection", async () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
run_id: null,
|
||||
signals: [],
|
||||
status: "no_data",
|
||||
target_trade_date: null,
|
||||
target_trade_date: "2026-08-08",
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
@@ -314,9 +435,12 @@ describe("SelectionResultsPage", () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByText("暂无策略结果")).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText("目标交易日"), {
|
||||
target: { value: "2026-08-08" },
|
||||
fireEvent.click(screen.getByRole("button", { name: "目标交易日" }))
|
||||
const calendar = await screen.findByRole("grid")
|
||||
const dateButton = within(calendar).getByRole("button", {
|
||||
name: "2026年8月9日 星期日",
|
||||
})
|
||||
fireEvent.click(dateButton)
|
||||
fireEvent.click(screen.getByRole("button", { name: "执行策略" }))
|
||||
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
@@ -324,13 +448,28 @@ describe("SelectionResultsPage", () => {
|
||||
request: {
|
||||
rerun: false,
|
||||
strategy: "zhixing_b1",
|
||||
target_trade_date: "2026-08-08",
|
||||
target_trade_date: "2026-08-09",
|
||||
},
|
||||
},
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps the target trade date empty after clearing the date picker", async () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
const dateTrigger = screen.getByRole("button", { name: "目标交易日" })
|
||||
fireEvent.click(dateTrigger)
|
||||
const calendar = await screen.findByRole("grid")
|
||||
fireEvent.click(
|
||||
within(calendar).getByRole("button", {
|
||||
name: "2026年8月8日 星期六,已选择",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(dateTrigger).toHaveTextContent("选择日期")
|
||||
})
|
||||
|
||||
it("continues polling a persisted running run after page reload", () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: { ...selectedResult, signals: [], status: "running" },
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
import { AlertTriangle, Play, RefreshCw, Search } from "lucide-react"
|
||||
import { useSearch } from "@tanstack/react-router"
|
||||
import { useRef, useState, type RefObject } from "react"
|
||||
|
||||
@@ -18,7 +12,6 @@ import type {
|
||||
SelectionResults,
|
||||
SelectionResultsQuery,
|
||||
} from "@/features/selection/api/selection.types"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
Card,
|
||||
@@ -35,17 +28,28 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog"
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { DatePicker } from "@/shared/ui/date-picker"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select"
|
||||
import { Skeleton } from "@/shared/ui/skeleton"
|
||||
|
||||
import { ExecutionStatusDrawer } from "../components/execution-status-drawer"
|
||||
import { getSelectionRunStatusPresentation } from "../components/selection-presentation"
|
||||
import { ExecutionStatusTrigger } from "../components/execution-status-trigger"
|
||||
import { SelectionResultsWorkbench } from "../components/selection-results-workbench"
|
||||
|
||||
const STRATEGY = "zhixing_b1" as const
|
||||
|
||||
export function SelectionResultsPage() {
|
||||
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
|
||||
// undefined follows the persisted result; null means the user cleared the picker.
|
||||
const [targetTradeDate, setTargetTradeDate] = useState<
|
||||
string | null | undefined
|
||||
>(undefined)
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null)
|
||||
const [executionStatusDrawerOpen, setExecutionStatusDrawerOpen] =
|
||||
useState(false)
|
||||
@@ -71,7 +75,9 @@ export function SelectionResultsPage() {
|
||||
const trigger = useTriggerSelectionRun()
|
||||
|
||||
const selectedTargetTradeDate =
|
||||
targetTradeDate ?? results.data?.target_trade_date ?? ""
|
||||
targetTradeDate === undefined
|
||||
? (results.data?.target_trade_date ?? "")
|
||||
: (targetTradeDate ?? "")
|
||||
|
||||
const selectedDateResult =
|
||||
targetTradeDate &&
|
||||
@@ -116,25 +122,13 @@ export function SelectionResultsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function handleDateChange(value: string) {
|
||||
setTargetTradeDate(value)
|
||||
function handleDateChange(value: Date | undefined) {
|
||||
setTargetTradeDate(value ? formatLocalTradeDate(value) : null)
|
||||
setExecutionStatusDrawerOpen(false)
|
||||
setActiveRunId(null)
|
||||
trigger.reset()
|
||||
}
|
||||
|
||||
const resultMetrics =
|
||||
displayedResult &&
|
||||
displayedResult.status !== "no_data" &&
|
||||
displayedResult.status !== "running" ? (
|
||||
<ResultMetrics
|
||||
drawerOpen={executionStatusDrawerOpen}
|
||||
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
|
||||
result={displayedResult}
|
||||
triggerRef={executionStatusTriggerRef}
|
||||
/>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout
|
||||
@@ -149,7 +143,6 @@ export function SelectionResultsPage() {
|
||||
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">
|
||||
@@ -169,14 +162,24 @@ export function SelectionResultsPage() {
|
||||
{!isRunning &&
|
||||
!hasQueryError &&
|
||||
displayedResult?.status === "failed" ? (
|
||||
<FailureState result={displayedResult} />
|
||||
<FailureState
|
||||
drawerOpen={executionStatusDrawerOpen}
|
||||
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
|
||||
result={displayedResult}
|
||||
triggerRef={executionStatusTriggerRef}
|
||||
/>
|
||||
) : null}
|
||||
{!isRunning &&
|
||||
!hasQueryError &&
|
||||
displayedResult &&
|
||||
displayedResult.status !== "no_data" &&
|
||||
displayedResult.status !== "failed" ? (
|
||||
<ResultState result={displayedResult} />
|
||||
<ResultState
|
||||
drawerOpen={executionStatusDrawerOpen}
|
||||
onOpenExecutionStatus={() => setExecutionStatusDrawerOpen(true)}
|
||||
result={displayedResult}
|
||||
triggerRef={executionStatusTriggerRef}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -227,7 +230,7 @@ interface ExecutionToolbarProps {
|
||||
hasExistingResult: boolean
|
||||
isFailedResult: boolean
|
||||
isRunning: boolean
|
||||
onDateChange: (value: string) => void
|
||||
onDateChange: (value: Date | undefined) => void
|
||||
onExecute: () => void
|
||||
selectedTargetTradeDate: string
|
||||
triggerError: boolean
|
||||
@@ -245,33 +248,35 @@ function ExecutionToolbar({
|
||||
return (
|
||||
<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"
|
||||
>
|
||||
目标交易日
|
||||
<Input
|
||||
className="mt-1 h-11 bg-background md:h-8"
|
||||
id="target-trade-date"
|
||||
onChange={(event) => onDateChange(event.target.value)}
|
||||
type="date"
|
||||
value={selectedTargetTradeDate}
|
||||
<div className="space-y-1 text-xs font-medium">
|
||||
<span className="block">目标交易日</span>
|
||||
<DatePicker
|
||||
aria-label="目标交易日"
|
||||
className="mt-1 h-11 w-full bg-background md:h-8"
|
||||
onChange={onDateChange}
|
||||
value={parseLocalTradeDate(selectedTargetTradeDate)}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className="space-y-1 text-xs font-medium"
|
||||
htmlFor="strategy-name"
|
||||
>
|
||||
策略
|
||||
<select
|
||||
className="mt-1 h-11 w-full rounded-lg border border-input bg-background px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50 md:h-8"
|
||||
</div>
|
||||
<div className="space-y-1 text-xs font-medium">
|
||||
<span className="block">策略</span>
|
||||
<Select
|
||||
disabled
|
||||
id="strategy-name"
|
||||
items={[{ label: "知行 B1", value: STRATEGY }]}
|
||||
value={STRATEGY}
|
||||
>
|
||||
<option value={STRATEGY}>知行 B1</option>
|
||||
</select>
|
||||
</label>
|
||||
<SelectTrigger
|
||||
aria-label="策略"
|
||||
className="mt-1 h-11 w-full bg-background md:h-8"
|
||||
>
|
||||
<SelectValue placeholder="选择策略" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={STRATEGY}>知行 B1</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
className="h-11 w-full sm:w-auto md:h-8"
|
||||
disabled={!selectedTargetTradeDate || isRunning}
|
||||
@@ -302,56 +307,36 @@ function ExecutionToolbar({
|
||||
)
|
||||
}
|
||||
|
||||
function ResultMetrics({
|
||||
drawerOpen,
|
||||
onOpenExecutionStatus,
|
||||
result,
|
||||
triggerRef,
|
||||
}: {
|
||||
interface ResultStateProps {
|
||||
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-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} />
|
||||
<Metric label="信号条数" value={result.signal_count} />
|
||||
<Metric
|
||||
label="数据覆盖率"
|
||||
value={`${(result.coverage * 100).toFixed(2)}%`}
|
||||
/>
|
||||
{presentation ? (
|
||||
<div className="space-y-1">
|
||||
<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}>
|
||||
{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 parseLocalTradeDate(value: string): Date | undefined {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
|
||||
if (!match) return undefined
|
||||
|
||||
const year = Number(match[1])
|
||||
const month = Number(match[2])
|
||||
const day = Number(match[3])
|
||||
const date = new Date(year, month - 1, day)
|
||||
|
||||
if (
|
||||
date.getFullYear() !== year ||
|
||||
date.getMonth() !== month - 1 ||
|
||||
date.getDate() !== day
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
function formatLocalTradeDate(value: Date): string {
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(value.getDate()).padStart(2, "0")
|
||||
return `${value.getFullYear()}-${month}-${day}`
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
@@ -423,7 +408,12 @@ function NoDataState() {
|
||||
)
|
||||
}
|
||||
|
||||
function FailureState({ result }: { result: SelectionResults }) {
|
||||
function FailureState({
|
||||
drawerOpen,
|
||||
onOpenExecutionStatus,
|
||||
result,
|
||||
triggerRef,
|
||||
}: ResultStateProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-1.5 p-4">
|
||||
@@ -440,49 +430,76 @@ function FailureState({ result }: { result: SelectionResults }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
目标交易日:
|
||||
<span className="font-medium text-foreground">
|
||||
{result.target_trade_date}
|
||||
</span>
|
||||
,失败股票:
|
||||
<span className="font-medium text-foreground">
|
||||
{result.failed_count}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
目标交易日:
|
||||
<span className="font-medium text-foreground">
|
||||
{result.target_trade_date}
|
||||
</span>
|
||||
,失败股票:
|
||||
<span className="font-medium text-foreground">
|
||||
{result.failed_count}
|
||||
</span>
|
||||
</p>
|
||||
<ExecutionStatusTrigger
|
||||
drawerOpen={drawerOpen}
|
||||
onOpen={onOpenExecutionStatus}
|
||||
result={result}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultState({ result }: { result: SelectionResults }) {
|
||||
function ResultState({
|
||||
drawerOpen,
|
||||
onOpenExecutionStatus,
|
||||
result,
|
||||
triggerRef,
|
||||
}: ResultStateProps) {
|
||||
return result.signal_count === 0 ? (
|
||||
<NoSignalState />
|
||||
<NoSignalState
|
||||
drawerOpen={drawerOpen}
|
||||
onOpenExecutionStatus={onOpenExecutionStatus}
|
||||
result={result}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
) : (
|
||||
<SelectionResultsWorkbench result={result} />
|
||||
<SelectionResultsWorkbench
|
||||
drawerOpen={drawerOpen}
|
||||
onOpenExecutionStatus={onOpenExecutionStatus}
|
||||
result={result}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NoSignalState() {
|
||||
function NoSignalState({
|
||||
drawerOpen,
|
||||
onOpenExecutionStatus,
|
||||
result,
|
||||
triggerRef,
|
||||
}: ResultStateProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="gap-1.5 p-4">
|
||||
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
||||
<CardDescription>
|
||||
目标交易日数据完整,但没有符合公式条件的股票。
|
||||
</CardDescription>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
||||
<CardDescription>
|
||||
目标交易日数据完整,但没有符合公式条件的股票。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<ExecutionStatusTrigger
|
||||
drawerOpen={drawerOpen}
|
||||
onOpen={onOpenExecutionStatus}
|
||||
result={result}
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[11px] text-muted-foreground">{label}</p>
|
||||
<p className="text-base font-semibold tracking-tight tabular-nums">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user