feat(selection): 补充策略执行结果查询链路

This commit is contained in:
yuxuanhui
2026-08-09 09:34:46 +08:00
parent e9d06df5de
commit 9c1a1eac23
31 changed files with 3566 additions and 14 deletions
@@ -13,16 +13,30 @@ import { Separator } from "@/shared/ui/separator"
interface HomeShellProps {
children: React.ReactNode
activeSection?: "home" | "selection"
}
const navigation = [
{ label: "首页", icon: LayoutDashboard, active: true },
{ label: "行情数据", icon: BarChart3, active: false },
{ label: "同步任务", icon: ClipboardCheck, active: false },
{ label: "选股策略", icon: BrainCircuit, active: false },
{
label: "首页",
icon: LayoutDashboard,
section: "home" as const,
path: "/" as const,
},
{ label: "行情数据", icon: BarChart3, section: null, path: null },
{ label: "同步任务", icon: ClipboardCheck, section: null, path: null },
{
label: "选股策略",
icon: BrainCircuit,
section: "selection" as const,
path: "/selection" as const,
},
]
export function HomeShell({ children }: HomeShellProps) {
export function HomeShell({
activeSection = "home",
children,
}: HomeShellProps) {
return (
<div className="flex min-h-screen bg-muted/30 text-foreground">
<aside className="flex w-20 shrink-0 flex-col border-r bg-card px-3 py-5 md:w-64 md:px-5">
@@ -43,16 +57,34 @@ export function HomeShell({ children }: HomeShellProps) {
<nav aria-label="主导航" className="space-y-1">
{navigation.map((item) => {
const Icon = item.icon
const active = item.section === activeSection
const content = (
<>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden md:inline">{item.label}</span>
</>
)
if (item.path) {
return (
<Button
key={item.label}
className="w-full justify-start gap-3 px-2 md:px-3"
render={<a href={item.path} />}
variant={active ? "secondary" : "ghost"}
aria-current={active ? "page" : undefined}
>
{content}
</Button>
)
}
return (
<Button
key={item.label}
className="w-full justify-start gap-3 px-2 md:px-3"
disabled={!item.active}
variant={item.active ? "secondary" : "ghost"}
aria-current={item.active ? "page" : undefined}
disabled
variant="ghost"
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden md:inline">{item.label}</span>
{content}
</Button>
)
})}
@@ -61,7 +61,10 @@ describe("HomePage", () => {
expect(screen.getByText("33.33%")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "行情数据" })).toBeDisabled()
expect(screen.getByRole("button", { name: "同步任务" })).toBeDisabled()
expect(screen.getByRole("button", { name: "选股策略" })).toBeDisabled()
expect(screen.getByRole("link", { name: "选股策略" })).toHaveAttribute(
"href",
"/selection",
)
})
it("opens and closes the accessible failed-stock dialog", () => {
@@ -22,7 +22,7 @@ export function HomePage() {
const update = overview.data?.latest_update
return (
<HomeShell>
<HomeShell activeSection="home">
<div className="space-y-8">
<section className="space-y-2">
<p className="text-sm font-medium text-primary">欢迎回到知行量化</p>
@@ -0,0 +1,39 @@
import { requestJson } from "@/shared/api/request-json"
import type {
SelectionResults,
SelectionRunAccepted,
SelectionRunRequest,
SelectionStrategy,
} from "./selection.types"
export function getSelectionResults(
strategy: SelectionStrategy,
targetTradeDate?: string,
signal?: AbortSignal,
) {
const params = new URLSearchParams({ strategy })
if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
return requestJson<SelectionResults>(
`/api/v1/selection/results?${params.toString()}`,
{ signal },
)
}
export function getSelectionRun(runId: string, signal?: AbortSignal) {
return requestJson<SelectionResults>(`/api/v1/selection/runs/${runId}`, {
signal,
})
}
export function triggerSelectionRun(
request: SelectionRunRequest,
signal?: AbortSignal,
) {
return requestJson<SelectionRunAccepted>("/api/v1/selection/runs", {
body: JSON.stringify(request),
headers: { "Content-Type": "application/json" },
method: "POST",
signal,
})
}
@@ -0,0 +1,79 @@
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query"
import { useEffect } from "react"
import {
getSelectionResults,
getSelectionRun,
triggerSelectionRun,
} from "./selection.api"
import type { SelectionRunRequest, SelectionStrategy } from "./selection.types"
export const selectionResultsQueryKey = (
strategy: SelectionStrategy,
targetTradeDate?: string,
) => ["selection", "results", strategy, targetTradeDate] as const
export const selectionRunQueryKey = (runId: string) =>
["selection", "runs", runId] as const
export function useSelectionResults(
strategy: SelectionStrategy,
targetTradeDate?: string,
) {
return useQuery({
queryFn: ({ signal }) =>
getSelectionResults(strategy, targetTradeDate, signal),
queryKey: selectionResultsQueryKey(strategy, targetTradeDate),
})
}
export function useSelectionRun(runId: string | null) {
const queryClient = useQueryClient()
const query = useQuery({
enabled: Boolean(runId),
queryFn: ({ signal }) => getSelectionRun(runId ?? "", signal),
queryKey: selectionRunQueryKey(runId ?? "none"),
refetchInterval: (query) =>
query.state.data?.status === "running" ? 1500 : false,
})
useEffect(() => {
const result = query.data
if (!runId || !result || result.status === "running") return
void invalidateSelectionResults(queryClient, result.strategy)
}, [query.data, queryClient, runId])
return query
}
export function useTriggerSelectionRun() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
request,
signal,
}: {
request: SelectionRunRequest
signal?: AbortSignal
}) => triggerSelectionRun(request, signal),
onSuccess: (_accepted, { request }) => {
void invalidateSelectionResults(queryClient, request.strategy)
},
})
}
export function invalidateSelectionResults(
queryClient: QueryClient,
strategy: SelectionStrategy,
) {
return queryClient.invalidateQueries({
queryKey: ["selection", "results", strategy],
})
}
@@ -0,0 +1,55 @@
export type SelectionStrategy = "zhixing_b1"
export type SelectionRunStatus =
"no_data" | "running" | "success" | "partial_success" | "failed"
export interface SelectionRunRequest {
strategy: SelectionStrategy
target_trade_date: string
rerun: boolean
}
export interface SelectionRunAccepted {
run_id: string
strategy: SelectionStrategy
target_trade_date: string
status: "running"
}
export interface SelectionSignal {
ts_code: string
name: string
target_trade_date: string
strategy: SelectionStrategy
category: string
close: number
details: Record<string, number | string | null>
}
export interface SelectionFailure {
ts_code: string
name: string
status: string
reason: string | null
}
export interface SelectionResults {
strategy: SelectionStrategy
target_trade_date: string | null
run_id: string | null
market_sync_batch_id: string | null
status: SelectionRunStatus
target_count: number
eligible_count: number
evaluated_count: number
selected_stock_count: number
signal_count: number
failed_count: number
coverage: number
error_type: string | null
error_message: string | null
created_at: string | null
finished_at: string | null
failures: SelectionFailure[]
signals: SelectionSignal[]
}
@@ -0,0 +1,216 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { SelectionResults } from "../api/selection.types"
import { SelectionResultsPage } from "./selection-results-page"
const useSelectionResults = vi.fn()
const useSelectionRun = vi.fn()
const useTriggerSelectionRun = vi.fn()
const mutate = vi.fn()
const reset = vi.fn()
vi.mock("@/features/selection/api/selection.query", () => ({
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
useTriggerSelectionRun: () => useTriggerSelectionRun(),
}))
const selectedResult: SelectionResults = {
created_at: "2026-08-08T09:00:00+08:00",
coverage: 1,
eligible_count: 2,
evaluated_count: 2,
error_message: null,
error_type: null,
failed_count: 0,
failures: [],
finished_at: "2026-08-08T09:02:00+08:00",
market_sync_batch_id: "market-run-1",
run_id: "run-1",
selected_stock_count: 1,
signal_count: 2,
signals: [
{
category: "zhixing_b1_original_b1",
close: 10.5,
details: { j: 12, rsi: 20 },
name: "平安银行",
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
ts_code: "000001.SZ",
},
{
category: "zhixing_b1_pullback_white",
close: 10.5,
details: { j: 12 },
name: "平安银行",
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
ts_code: "000001.SZ",
},
],
status: "success",
strategy: "zhixing_b1",
target_count: 2,
target_trade_date: "2026-08-08",
}
describe("SelectionResultsPage", () => {
beforeEach(() => {
useSelectionResults.mockReturnValue({
data: selectedResult,
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue({
data: undefined,
isError: false,
isPending: false,
})
useTriggerSelectionRun.mockReturnValue({
isError: false,
isPending: false,
mutate,
reset,
})
mutate.mockReset()
reset.mockReset()
})
it("renders persisted results and keeps multiple categories visible", () => {
render(<SelectionResultsPage />)
expect(
screen.getByRole("heading", { name: "选股策略" }),
).toBeInTheDocument()
expect(screen.getByText("知行 B1 执行结果")).toBeInTheDocument()
expect(screen.getByText("原始 B1")).toBeInTheDocument()
expect(screen.getByText("回踩白线 B")).toBeInTheDocument()
expect(screen.getAllByText("平安银行")).toHaveLength(2)
})
it("requires confirmation before a rerun and does not trigger on cancel", () => {
render(<SelectionResultsPage />)
fireEvent.click(screen.getByRole("button", { name: "重新执行" }))
const dialog = screen.getByRole("dialog", { name: "确认重新执行策略?" })
expect(within(dialog).getByText(/清空 2026-08-08/)).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole("button", { name: "取消" }))
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole("button", { name: "重新执行" }))
fireEvent.click(
within(
screen.getByRole("dialog", { name: "确认重新执行策略?" }),
).getByRole("button", { name: "确认重新执行" }),
)
expect(mutate).toHaveBeenCalledWith(
{
request: {
rerun: true,
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
},
},
expect.any(Object),
)
})
it("uses the same confirmation flow for a failed retry", () => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
failed_count: 1,
failures: [
{
name: "平安银行",
reason: "目标日数据读取失败",
status: "data_error",
ts_code: "000001.SZ",
},
],
selected_stock_count: 0,
signal_count: 0,
signals: [],
status: "failed",
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
fireEvent.click(screen.getByRole("button", { name: "重试执行" }))
expect(
screen.getByRole("dialog", { name: "确认重新执行策略?" }),
).toBeInTheDocument()
})
it("renders a no-data state and triggers an initial run after date selection", () => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
run_id: null,
signals: [],
status: "no_data",
target_trade_date: null,
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("暂无策略结果")).toBeInTheDocument()
fireEvent.change(screen.getByLabelText("目标交易日"), {
target: { value: "2026-08-08" },
})
fireEvent.click(screen.getByRole("button", { name: "执行策略" }))
expect(mutate).toHaveBeenCalledWith(
{
request: {
rerun: false,
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
},
},
expect.any(Object),
)
})
it("continues polling a persisted running run after page reload", () => {
useSelectionResults.mockReturnValue({
data: { ...selectedResult, signals: [], status: "running" },
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue({
data: { ...selectedResult, signals: [], status: "running" },
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("策略执行中")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled()
expect(useSelectionRun).toHaveBeenCalledWith("run-1")
})
it("renders query failures explicitly", () => {
useSelectionResults.mockReturnValue({
data: undefined,
isError: true,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("结果查询失败")).toBeInTheDocument()
})
})
@@ -0,0 +1,516 @@
import {
AlertTriangle,
CheckCircle2,
Play,
RefreshCw,
Search,
} from "lucide-react"
import { useState } from "react"
import {
useSelectionResults,
useSelectionRun,
useTriggerSelectionRun,
} from "@/features/selection/api/selection.query"
import type {
SelectionResults,
SelectionRunStatus,
SelectionSignal,
} from "@/features/selection/api/selection.types"
import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog"
import { Input } from "@/shared/ui/input"
import { Skeleton } from "@/shared/ui/skeleton"
import { HomeShell } from "@/features/home/components/home-shell"
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" },
}
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)
const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
const results = useSelectionResults(STRATEGY, targetTradeDate || undefined)
const persistedRunningRunId =
results.data?.status === "running" ? results.data.run_id : null
const polledRunId = activeRunId ?? persistedRunningRunId
const activeRun = useSelectionRun(polledRunId)
const trigger = useTriggerSelectionRun()
const selectedTargetTradeDate =
targetTradeDate ?? results.data?.target_trade_date ?? ""
const selectedDateResult =
targetTradeDate &&
results.data?.target_trade_date !== selectedTargetTradeDate
? undefined
: results.data
const displayedResult = activeRun.data ?? selectedDateResult
const isRunning =
trigger.isPending ||
results.data?.status === "running" ||
activeRun.data?.status === "running" ||
(polledRunId !== null && activeRun.isPending)
const hasExistingResult = Boolean(
displayedResult?.run_id && displayedResult.status !== "no_data",
)
const hasQueryError =
activeRunId !== null ? activeRun.isError : results.isError
function handleExecute() {
if (!selectedTargetTradeDate || isRunning) return
if (hasExistingResult) {
setRerunDialogOpen(true)
return
}
startExecution(false)
}
function startExecution(rerun: boolean) {
if (!selectedTargetTradeDate) return
setRerunDialogOpen(false)
setActiveRunId(null)
trigger.mutate(
{
request: {
strategy: STRATEGY,
target_trade_date: selectedTargetTradeDate,
rerun,
},
},
{ onSuccess: (accepted) => setActiveRunId(accepted.run_id) },
)
}
function handleDateChange(value: string) {
setTargetTradeDate(value)
setActiveRunId(null)
trigger.reset()
}
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>
{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>
) : null}
</HomeShell>
)
}
function LoadingState() {
return (
<Card aria-label="正在加载策略执行结果">
<CardHeader className="gap-3">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72 max-w-full" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
)
}
function RunningState() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<RefreshCw
aria-hidden="true"
className="size-5 animate-spin text-primary"
/>
策略执行中
</CardTitle>
<CardDescription>
正在逐只评估当前有效股票池,页面会自动刷新执行状态。
</CardDescription>
</CardHeader>
</Card>
)
}
function QueryError() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<AlertTriangle
aria-hidden="true"
className="size-5 text-destructive"
/>
结果查询失败
</CardTitle>
<CardDescription>
无法读取策略执行结果,请稍后刷新页面重试。
</CardDescription>
</CardHeader>
</Card>
)
}
function NoDataState() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<Search aria-hidden="true" className="size-5 text-primary" />
暂无策略结果
</CardTitle>
<CardDescription>
请选择目标交易日并执行知行 B1,完成后这里会显示持久化结果。
</CardDescription>
</CardHeader>
</Card>
)
}
function FailureState({ result }: { result: SelectionResults }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<AlertTriangle
aria-hidden="true"
className="size-5 text-destructive"
/>
策略执行失败
</CardTitle>
<CardDescription>
{result.error_message ??
"本次执行没有形成可用的策略结果,请确认后重试。"}
</CardDescription>
</CardHeader>
<CardContent>
<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>
</CardContent>
</Card>
)
}
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>
{result.signal_count === 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
<CardDescription>
目标交易日数据完整,但没有符合公式条件的股票。
</CardDescription>
</CardHeader>
</Card>
) : (
<SignalTable signals={result.signals} />
)}
{result.failures.length > 0 ? <FailureList result={result} /> : null}
</div>
)
}
function SignalTable({ signals }: { signals: SelectionSignal[] }) {
return (
<Card>
<CardHeader>
<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>
)
}
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-1">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-lg font-semibold tracking-tight">{value}</p>
</div>
)
}
function formatDetails(details: SelectionSignal["details"]) {
return Object.entries(details)
.map(([key, value]) => `${key}: ${value ?? "—"}`)
.join(" · ")
}
+8 -1
View File
@@ -1,6 +1,7 @@
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
import { HomePage } from "@/features/home/pages/home-page"
import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page"
const rootRoute = createRootRoute({
component: () => <Outlet />,
@@ -12,4 +13,10 @@ const indexRoute = createRoute({
component: HomePage,
})
export const routeTree = rootRoute.addChildren([indexRoute])
const selectionRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/selection",
component: SelectionResultsPage,
})
export const routeTree = rootRoute.addChildren([indexRoute, selectionRoute])