feat(web): 按原型调整响应式研究工作台

This commit is contained in:
yuxuanhui
2026-08-10 10:07:55 +08:00
parent 5b650166b5
commit ed7bdda515
16 changed files with 1319 additions and 350 deletions
@@ -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(" · ")
}