feat(web): 按原型调整响应式研究工作台
This commit is contained in:
@@ -5,6 +5,7 @@ import type { SelectionResults } from "../api/selection.types"
|
||||
|
||||
import { SelectionResultsPage } from "./selection-results-page"
|
||||
|
||||
const routerNavigate = vi.hoisted(() => vi.fn())
|
||||
const useSelectionResults = vi.fn()
|
||||
const useSelectionRun = vi.fn()
|
||||
const useTriggerSelectionRun = vi.fn()
|
||||
@@ -17,6 +18,11 @@ vi.mock("@/features/selection/api/selection.query", () => ({
|
||||
useTriggerSelectionRun: () => useTriggerSelectionRun(),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useNavigate: () => routerNavigate,
|
||||
useSearch: () => ({ page: 1, pageSize: 5 }),
|
||||
}))
|
||||
|
||||
const selectedResult: SelectionResults = {
|
||||
created_at: "2026-08-08T09:00:00+08:00",
|
||||
coverage: 1,
|
||||
@@ -77,18 +83,75 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
mutate.mockReset()
|
||||
reset.mockReset()
|
||||
routerNavigate.mockReset()
|
||||
})
|
||||
|
||||
it("renders persisted results and keeps multiple categories visible", () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByText("执行完成")).toBeInTheDocument()
|
||||
const table = screen.getByRole("table")
|
||||
expect(table).toBeInTheDocument()
|
||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
|
||||
expect(within(table).getAllByText("平安银行")).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("filters signals, updates the detail panel, and expands mobile details", () => {
|
||||
const extraSignal = {
|
||||
...selectedResult.signals[0],
|
||||
category: "zhixing_b1_oversold_volume",
|
||||
details: { macd: 3.2 },
|
||||
name: "招商银行",
|
||||
ts_code: "600036.SH",
|
||||
}
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
signal_count: 3,
|
||||
signals: [...selectedResult.signals, extraSignal],
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText("搜索命中信号"), {
|
||||
target: { value: "招商" },
|
||||
})
|
||||
const table = screen.getByRole("table")
|
||||
expect(within(table).getByText("招商银行")).toBeInTheDocument()
|
||||
fireEvent.click(within(table).getByText("招商银行"))
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "选股策略" }),
|
||||
within(
|
||||
screen.getByRole("complementary", { name: "当前股票详情" }),
|
||||
).getByText("招商银行"),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("知行 B1 执行结果")).toBeInTheDocument()
|
||||
expect(screen.getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(screen.getByText("回踩白线 B")).toBeInTheDocument()
|
||||
expect(screen.getAllByText("平安银行")).toHaveLength(2)
|
||||
|
||||
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[0]
|
||||
fireEvent.click(expandButton)
|
||||
expect(expandButton).toHaveAttribute("aria-expanded", "true")
|
||||
expect(screen.getAllByText("macd: 3.2").length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it("navigates to another page when the result list is paginated", () => {
|
||||
const signals = Array.from({ length: 6 }, (_, index) => ({
|
||||
...selectedResult.signals[0],
|
||||
name: `股票${index + 1}`,
|
||||
ts_code: `00000${index + 1}.SZ`,
|
||||
}))
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: { ...selectedResult, signal_count: signals.length, signals },
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByText("第 1–5 条,共 6 条")).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "第 2 页" }))
|
||||
expect(routerNavigate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("requires confirmation before a rerun and does not trigger on cancel", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { PageLayout } from "@/app/layout/page-layout"
|
||||
import {
|
||||
useSelectionResults,
|
||||
useSelectionRun,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
import type {
|
||||
SelectionResults,
|
||||
SelectionRunStatus,
|
||||
SelectionSignal,
|
||||
} from "@/features/selection/api/selection.types"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { Skeleton } from "@/shared/ui/skeleton"
|
||||
|
||||
import { HomeShell } from "@/features/home/components/home-shell"
|
||||
import { SelectionResultsWorkbench } from "../components/selection-results-workbench"
|
||||
|
||||
const STRATEGY = "zhixing_b1" as const
|
||||
|
||||
@@ -50,16 +50,6 @@ const statusPresentation: Record<
|
||||
success: { label: "执行完成", variant: "default" },
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
zhixing_b1_extreme_volume: "超卖超缩量 B",
|
||||
zhixing_b1_original_b1: "原始 B1",
|
||||
zhixing_b1_oversold_turn: "超卖缩量拐头 B",
|
||||
zhixing_b1_oversold_volume: "超卖缩量 B",
|
||||
zhixing_b1_pullback_super: "回踩超级 B",
|
||||
zhixing_b1_pullback_white: "回踩白线 B",
|
||||
zhixing_b1_pullback_yellow: "回踩黄线 B",
|
||||
}
|
||||
|
||||
export function SelectionResultsPage() {
|
||||
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null)
|
||||
@@ -123,78 +113,30 @@ export function SelectionResultsPage() {
|
||||
trigger.reset()
|
||||
}
|
||||
|
||||
const resultMetrics =
|
||||
displayedResult &&
|
||||
displayedResult.status !== "no_data" &&
|
||||
displayedResult.status !== "running" ? (
|
||||
<ResultMetrics result={displayedResult} />
|
||||
) : null
|
||||
|
||||
return (
|
||||
<HomeShell activeSection="selection">
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-2">
|
||||
<p className="text-sm font-medium text-primary">研究策略执行</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">选股策略</h1>
|
||||
<p className="max-w-2xl text-muted-foreground">
|
||||
查看知行 B1 在指定交易日的持久化执行结果,或重新执行当天策略。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">执行条件</CardTitle>
|
||||
<CardDescription>
|
||||
策略只使用已完成且达到选股资格的市场数据同步批次。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<label
|
||||
className="flex-1 space-y-2 text-sm font-medium"
|
||||
htmlFor="target-trade-date"
|
||||
>
|
||||
目标交易日
|
||||
<Input
|
||||
id="target-trade-date"
|
||||
onChange={(event) => handleDateChange(event.target.value)}
|
||||
type="date"
|
||||
value={selectedTargetTradeDate}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className="flex-1 space-y-2 text-sm font-medium"
|
||||
htmlFor="strategy-name"
|
||||
>
|
||||
策略
|
||||
<select
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
disabled
|
||||
id="strategy-name"
|
||||
value={STRATEGY}
|
||||
>
|
||||
<option value={STRATEGY}>知行 B1</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
disabled={!selectedTargetTradeDate || isRunning}
|
||||
onClick={handleExecute}
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw aria-hidden="true" className="animate-spin" />
|
||||
) : hasExistingResult ? (
|
||||
<RefreshCw aria-hidden="true" />
|
||||
) : (
|
||||
<Play aria-hidden="true" />
|
||||
)}
|
||||
{isRunning
|
||||
? "执行中"
|
||||
: hasExistingResult
|
||||
? displayedResult?.status === "failed"
|
||||
? "重试执行"
|
||||
: "重新执行"
|
||||
: "执行策略"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
{trigger.isError ? (
|
||||
<p className="px-6 pb-6 text-sm text-destructive" role="alert">
|
||||
执行请求失败,请确认市场数据状态后重试。
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<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 &&
|
||||
@@ -249,10 +191,128 @@ export function SelectionResultsPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</HomeShell>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
interface ExecutionToolbarProps {
|
||||
hasExistingResult: boolean
|
||||
isFailedResult: boolean
|
||||
isRunning: boolean
|
||||
onDateChange: (value: string) => void
|
||||
onExecute: () => void
|
||||
selectedTargetTradeDate: string
|
||||
triggerError: boolean
|
||||
}
|
||||
|
||||
function ExecutionToolbar({
|
||||
hasExistingResult,
|
||||
isFailedResult,
|
||||
isRunning,
|
||||
onDateChange,
|
||||
onExecute,
|
||||
selectedTargetTradeDate,
|
||||
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">
|
||||
<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}
|
||||
/>
|
||||
</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"
|
||||
disabled
|
||||
id="strategy-name"
|
||||
value={STRATEGY}
|
||||
>
|
||||
<option value={STRATEGY}>知行 B1</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
className="h-11 w-full sm:w-auto md:h-8"
|
||||
disabled={!selectedTargetTradeDate || isRunning}
|
||||
onClick={onExecute}
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw aria-hidden="true" className="animate-spin" />
|
||||
) : hasExistingResult ? (
|
||||
<RefreshCw aria-hidden="true" />
|
||||
) : (
|
||||
<Play aria-hidden="true" />
|
||||
)}
|
||||
{isRunning
|
||||
? "执行中"
|
||||
: hasExistingResult
|
||||
? isFailedResult
|
||||
? "重试执行"
|
||||
: "重新执行"
|
||||
: "执行策略"}
|
||||
</Button>
|
||||
</div>
|
||||
{triggerError ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
执行请求失败,请确认市场数据状态后重试。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultMetrics({ result }: { result: SelectionResults }) {
|
||||
const presentation = getStatusPresentation(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]">
|
||||
<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>
|
||||
<Badge className="gap-1" variant={presentation.variant}>
|
||||
{result.status === "success" ? (
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
) : null}
|
||||
{presentation.label}
|
||||
</Badge>
|
||||
</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="正在加载策略执行结果">
|
||||
@@ -355,111 +415,27 @@ function FailureState({ result }: { result: SelectionResults }) {
|
||||
}
|
||||
|
||||
function ResultState({ result }: { result: SelectionResults }) {
|
||||
const presentation =
|
||||
result.status === "success" ||
|
||||
result.status === "partial_success" ||
|
||||
result.status === "failed"
|
||||
? statusPresentation[result.status]
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="gap-4 border-b md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<CheckCircle2
|
||||
aria-hidden="true"
|
||||
className="size-5 text-primary"
|
||||
/>
|
||||
知行 B1 执行结果
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
目标交易日:{result.target_trade_date} · 市场同步批次:
|
||||
{result.market_sync_batch_id}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{presentation ? (
|
||||
<Badge variant={presentation.variant}>{presentation.label}</Badge>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 pt-6 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<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)}%`}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||
{result.signal_count === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
||||
<CardDescription>
|
||||
目标交易日数据完整,但没有符合公式条件的股票。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<NoSignalState />
|
||||
) : (
|
||||
<SignalTable signals={result.signals} />
|
||||
<SelectionResultsWorkbench result={result} />
|
||||
)}
|
||||
|
||||
{result.failures.length > 0 ? <FailureList result={result} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SignalTable({ signals }: { signals: SelectionSignal[] }) {
|
||||
function NoSignalState() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">命中信号明细</CardTitle>
|
||||
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
||||
<CardDescription>
|
||||
同一股票同日的多个子信号分别保留,不合并分类。
|
||||
目标交易日数据完整,但没有符合公式条件的股票。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pb-3 pr-4 font-medium">股票</th>
|
||||
<th className="pb-3 pr-4 font-medium">子信号</th>
|
||||
<th className="pb-3 pr-4 font-medium">收盘价</th>
|
||||
<th className="pb-3 font-medium">关键详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signals.map((signal) => (
|
||||
<tr
|
||||
key={`${signal.ts_code}-${signal.category}`}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-3 pr-4">
|
||||
<p className="font-medium">{signal.name}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{signal.ts_code}
|
||||
</p>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge variant="outline">
|
||||
{categoryLabels[signal.category] ?? signal.category}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums">
|
||||
{signal.close.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-3 text-muted-foreground">
|
||||
{formatDetails(signal.details)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -502,15 +478,11 @@ function FailureList({ result }: { result: SelectionResults }) {
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="text-lg font-semibold tracking-tight">{value}</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDetails(details: SelectionSignal["details"]) {
|
||||
return Object.entries(details)
|
||||
.map(([key, value]) => `${key}: ${value ?? "—"}`)
|
||||
.join(" · ")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user