refactor(selection): 统一信号与股票处理逻辑,重构相关数据结构与接口,更新前端展示以支持股票信息
This commit is contained in:
@@ -43,6 +43,15 @@ export interface SelectionSignal {
|
||||
details: Record<string, number | string | null>
|
||||
}
|
||||
|
||||
export interface SelectionStockResult {
|
||||
ts_code: string
|
||||
name: string
|
||||
target_trade_date: string
|
||||
strategy: SelectionStrategy
|
||||
close: number
|
||||
signals: SelectionSignal[]
|
||||
}
|
||||
|
||||
export interface SelectionFailure {
|
||||
ts_code: string
|
||||
name: string
|
||||
@@ -69,7 +78,7 @@ export interface SelectionResults {
|
||||
finished_at: string | null
|
||||
page: number
|
||||
page_size: number
|
||||
signals_total: number
|
||||
stocks_total: number
|
||||
failures: SelectionFailure[]
|
||||
signals: SelectionSignal[]
|
||||
stocks: SelectionStockResult[]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
SelectionCategoryFilter,
|
||||
SelectionRunStatus,
|
||||
SelectionSignal,
|
||||
SelectionStockResult,
|
||||
} from "../api/selection.types"
|
||||
|
||||
export type SelectionRunStatusPresentation = {
|
||||
@@ -73,6 +74,10 @@ export function getSignalKey(signal: SelectionSignal) {
|
||||
return `${signal.ts_code}-${signal.category}`
|
||||
}
|
||||
|
||||
export function getStockKey(stock: SelectionStockResult) {
|
||||
return stock.ts_code
|
||||
}
|
||||
|
||||
export function getSignalDetailItems(details: SelectionSignal["details"]) {
|
||||
return Object.entries(details).map(([key, value]) => ({
|
||||
key,
|
||||
@@ -102,11 +107,11 @@ function formatSignalDetailValue(
|
||||
}
|
||||
|
||||
export function matchesCategory(
|
||||
signal: SelectionSignal,
|
||||
stock: SelectionStockResult,
|
||||
category: SignalCategoryFilter,
|
||||
) {
|
||||
if (category === "all") return true
|
||||
return signal.category.includes(category)
|
||||
return stock.signals.some((signal) => signal.category.includes(category))
|
||||
}
|
||||
|
||||
export function categoryToneClass(category: string) {
|
||||
|
||||
@@ -12,12 +12,15 @@ import {
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select"
|
||||
|
||||
import type { SelectionResults, SelectionSignal } from "../api/selection.types"
|
||||
import type {
|
||||
SelectionResults,
|
||||
SelectionStockResult,
|
||||
} from "../api/selection.types"
|
||||
import { ExecutionStatusTrigger } from "./execution-status-trigger"
|
||||
import { SignalDetailPanel } from "./signal-detail-panel"
|
||||
import { SignalRecordList } from "./signal-record-list"
|
||||
import {
|
||||
getSignalKey,
|
||||
getStockKey,
|
||||
matchesCategory,
|
||||
signalCategoryOptions,
|
||||
type SignalCategoryFilter,
|
||||
@@ -48,26 +51,26 @@ export function SelectionResultsWorkbench({
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const visibleSignals = useMemo(() => {
|
||||
const visibleStocks = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
return result.signals.filter((signal) => {
|
||||
return result.stocks.filter((stock) => {
|
||||
const matchesQuery =
|
||||
normalized.length === 0 ||
|
||||
signal.name.toLowerCase().includes(normalized) ||
|
||||
signal.ts_code.toLowerCase().includes(normalized)
|
||||
return matchesQuery && matchesCategory(signal, category)
|
||||
stock.name.toLowerCase().includes(normalized) ||
|
||||
stock.ts_code.toLowerCase().includes(normalized)
|
||||
return matchesQuery && matchesCategory(stock, category)
|
||||
})
|
||||
}, [category, query, result.signals])
|
||||
}, [category, query, result.stocks])
|
||||
|
||||
const page = search.page
|
||||
const pageSize = search.pageSize
|
||||
const signalsTotal = result.signals_total ?? result.signal_count
|
||||
const pageCount = Math.max(1, Math.ceil(signalsTotal / pageSize))
|
||||
const stocksTotal = result.stocks_total ?? result.selected_stock_count
|
||||
const pageCount = Math.max(1, Math.ceil(stocksTotal / pageSize))
|
||||
const currentPage = Math.min(page, pageCount)
|
||||
const currentSignals = visibleSignals
|
||||
const selectedSignal =
|
||||
currentSignals.find((signal) => getSignalKey(signal) === selectedKey) ??
|
||||
currentSignals[0]
|
||||
const currentStocks = visibleStocks
|
||||
const selectedStock =
|
||||
currentStocks.find((stock) => getStockKey(stock) === selectedKey) ??
|
||||
currentStocks[0]
|
||||
useEffect(() => {
|
||||
if (page > pageCount) {
|
||||
void navigate({
|
||||
@@ -93,8 +96,8 @@ export function SelectionResultsWorkbench({
|
||||
updateSearch({ category: value, page: 1 })
|
||||
}
|
||||
|
||||
function handleToggleExpanded(signal: SelectionSignal) {
|
||||
const key = getSignalKey(signal)
|
||||
function handleToggleExpanded(stock: SelectionStockResult) {
|
||||
const key = getStockKey(stock)
|
||||
setExpandedKeys((previous) => {
|
||||
const next = new Set(previous)
|
||||
if (next.has(key)) next.delete(key)
|
||||
@@ -108,7 +111,7 @@ export function SelectionResultsWorkbench({
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-visible rounded-md border border-border/80 bg-card md:overflow-hidden">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
|
||||
<Input
|
||||
aria-label="搜索命中信号"
|
||||
aria-label="搜索命中股票"
|
||||
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="搜索股票名称或代码"
|
||||
@@ -130,7 +133,7 @@ export function SelectionResultsWorkbench({
|
||||
>
|
||||
<SelectValue placeholder="筛选信号分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent align="start" alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{signalCategoryOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
@@ -141,7 +144,7 @@ export function SelectionResultsWorkbench({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs tabular-nums text-muted-foreground sm:ml-auto">
|
||||
筛选结果 {signalsTotal} 条
|
||||
筛选结果 {stocksTotal} 只
|
||||
</span>
|
||||
<ExecutionStatusTrigger
|
||||
drawerOpen={drawerOpen}
|
||||
@@ -153,15 +156,15 @@ export function SelectionResultsWorkbench({
|
||||
|
||||
<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}
|
||||
signals={currentSignals}
|
||||
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
||||
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
||||
stocks={currentStocks}
|
||||
/>
|
||||
<SignalRecordList
|
||||
expandedKeys={expandedKeys}
|
||||
onSelect={(signal) => setSelectedKey(getSignalKey(signal))}
|
||||
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
signals={currentSignals}
|
||||
stocks={currentStocks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -173,11 +176,11 @@ export function SelectionResultsWorkbench({
|
||||
page={currentPage}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
total={signalsTotal}
|
||||
total={stocksTotal}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<SignalDetailPanel signal={selectedSignal} />
|
||||
<SignalDetailPanel stock={selectedStock} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@ import { Database, Search } from "lucide-react"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Card } from "@/shared/ui/card"
|
||||
|
||||
import type { SelectionSignal } from "../api/selection.types"
|
||||
import type { SelectionStockResult } from "../api/selection.types"
|
||||
import { categoryToneClass, getCategoryLabel } from "./selection-presentation"
|
||||
import { SignalDetails } from "./signal-details"
|
||||
|
||||
interface SignalDetailPanelProps {
|
||||
signal: SelectionSignal | undefined
|
||||
stock: SelectionStockResult | undefined
|
||||
}
|
||||
|
||||
export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
||||
if (!signal) {
|
||||
export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||
if (!stock) {
|
||||
return (
|
||||
<Card
|
||||
aria-label="当前股票详情"
|
||||
@@ -23,9 +23,9 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
||||
className="mx-auto size-5 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="font-medium">暂无选中信号</p>
|
||||
<p className="font-medium">暂无选中股票</p>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
从左侧列表选择一条信号查看详情。
|
||||
从左侧列表选择一只股票查看详情。
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -38,15 +38,15 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
||||
className="hidden min-h-0 overflow-auto rounded-md 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
|
||||
Selected stock
|
||||
</p>
|
||||
<div className="mt-2 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-xl font-semibold tracking-tight">
|
||||
{signal.name}
|
||||
{stock.name}
|
||||
</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{signal.ts_code}
|
||||
{stock.ts_code}
|
||||
</p>
|
||||
</div>
|
||||
<Database
|
||||
@@ -56,21 +56,26 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-3xl font-semibold tabular-nums tracking-tight">
|
||||
¥ {signal.close.toFixed(2)}
|
||||
¥ {stock.close.toFixed(2)}
|
||||
</p>
|
||||
|
||||
<Badge
|
||||
className={`mt-3 ${categoryToneClass(signal.category)}`}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{stock.signals.map((signal) => (
|
||||
<Badge
|
||||
key={signal.category}
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 divide-y divide-border/60 border-y 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">
|
||||
{signal.target_trade_date}
|
||||
{stock.target_trade_date}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 py-2.5">
|
||||
@@ -86,7 +91,16 @@ export function SignalDetailPanel({ signal }: SignalDetailPanelProps) {
|
||||
>
|
||||
关键指标
|
||||
</h3>
|
||||
<SignalDetails details={signal.details} />
|
||||
<div className="space-y-4">
|
||||
{stock.signals.map((signal) => (
|
||||
<section key={signal.category} className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-foreground">
|
||||
{getCategoryLabel(signal.category)}
|
||||
</h4>
|
||||
<SignalDetails details={signal.details} />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -3,29 +3,29 @@ import { ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
import type { SelectionSignal } from "../api/selection.types"
|
||||
import type { SelectionStockResult } from "../api/selection.types"
|
||||
import {
|
||||
categoryToneClass,
|
||||
getCategoryLabel,
|
||||
getSignalJValue,
|
||||
getSignalKey,
|
||||
getStockKey,
|
||||
} from "./selection-presentation"
|
||||
import { SignalDetails } from "./signal-details"
|
||||
|
||||
interface SignalRecordListProps {
|
||||
signals: SelectionSignal[]
|
||||
stocks: SelectionStockResult[]
|
||||
expandedKeys: ReadonlySet<string>
|
||||
onSelect: (signal: SelectionSignal) => void
|
||||
onToggleExpanded: (signal: SelectionSignal) => void
|
||||
onSelect: (stock: SelectionStockResult) => void
|
||||
onToggleExpanded: (stock: SelectionStockResult) => void
|
||||
}
|
||||
|
||||
export function SignalRecordList({
|
||||
signals,
|
||||
stocks,
|
||||
expandedKeys,
|
||||
onSelect,
|
||||
onToggleExpanded,
|
||||
}: SignalRecordListProps) {
|
||||
if (signals.length === 0) {
|
||||
if (stocks.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-32 items-center justify-center px-4 text-sm text-muted-foreground md:hidden">
|
||||
没有符合当前筛选条件的信号。
|
||||
@@ -35,46 +35,53 @@ export function SignalRecordList({
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border/60 md:hidden">
|
||||
{signals.map((signal) => {
|
||||
const key = getSignalKey(signal)
|
||||
{stocks.map((stock) => {
|
||||
const key = getStockKey(stock)
|
||||
const expanded = expandedKeys.has(key)
|
||||
return (
|
||||
<article key={key} className="space-y-2 px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
onClick={() => onSelect(signal)}
|
||||
onClick={() => onSelect(stock)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium text-foreground">
|
||||
{signal.name}
|
||||
{stock.name}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{signal.ts_code}
|
||||
{stock.ts_code}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-medium tabular-nums text-foreground">
|
||||
¥ {signal.close.toFixed(2)}
|
||||
¥ {stock.close.toFixed(2)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Badge
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{stock.signals.map((signal) => (
|
||||
<Badge
|
||||
key={signal.category}
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
J
|
||||
<strong className="font-semibold text-foreground">
|
||||
{getSignalJValue(signal.details)}
|
||||
{stock.signals
|
||||
.map((signal) => getSignalJValue(signal.details))
|
||||
.join(" / ")}
|
||||
</strong>
|
||||
</span>
|
||||
<Button
|
||||
aria-expanded={expanded}
|
||||
className="min-h-11 gap-1 px-2 text-xs text-muted-foreground"
|
||||
onClick={() => onToggleExpanded(signal)}
|
||||
onClick={() => onToggleExpanded(stock)}
|
||||
variant="ghost"
|
||||
>
|
||||
{expanded ? "收起详情" : "查看详情"}
|
||||
@@ -87,10 +94,19 @@ export function SignalRecordList({
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<SignalDetails
|
||||
className="sm:grid-cols-3"
|
||||
details={signal.details}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{stock.signals.map((signal) => (
|
||||
<section key={signal.category} className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{getCategoryLabel(signal.category)}
|
||||
</p>
|
||||
<SignalDetails
|
||||
className="sm:grid-cols-3"
|
||||
details={signal.details}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -2,32 +2,32 @@ import type { KeyboardEvent } from "react"
|
||||
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
|
||||
import type { SelectionSignal } from "../api/selection.types"
|
||||
import type { SelectionStockResult } from "../api/selection.types"
|
||||
import {
|
||||
categoryToneClass,
|
||||
getCategoryLabel,
|
||||
getSignalJValue,
|
||||
getSignalKey,
|
||||
getStockKey,
|
||||
} from "./selection-presentation"
|
||||
|
||||
interface SignalTableProps {
|
||||
signals: SelectionSignal[]
|
||||
stocks: SelectionStockResult[]
|
||||
selectedKey: string | null
|
||||
onSelect: (signal: SelectionSignal) => void
|
||||
onSelect: (stock: SelectionStockResult) => void
|
||||
}
|
||||
|
||||
export function SignalTable({
|
||||
signals,
|
||||
stocks,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
}: SignalTableProps) {
|
||||
function handleRowKeyDown(
|
||||
event: KeyboardEvent<HTMLTableRowElement>,
|
||||
signal: SelectionSignal,
|
||||
stock: SelectionStockResult,
|
||||
) {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
onSelect(signal)
|
||||
onSelect(stock)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -43,38 +43,47 @@ export function SignalTable({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signals.length > 0 ? (
|
||||
signals.map((signal) => {
|
||||
const selected = selectedKey === getSignalKey(signal)
|
||||
{stocks.length > 0 ? (
|
||||
stocks.map((stock) => {
|
||||
const selected = selectedKey === getStockKey(stock)
|
||||
return (
|
||||
<tr
|
||||
key={getSignalKey(signal)}
|
||||
key={getStockKey(stock)}
|
||||
aria-selected={selected}
|
||||
className="cursor-pointer border-b border-border/50 outline-none transition-colors hover:bg-muted/50 focus-visible:bg-muted/60 data-[selected=true]:bg-muted/70"
|
||||
data-selected={selected}
|
||||
onClick={() => onSelect(signal)}
|
||||
onKeyDown={(event) => handleRowKeyDown(event, signal)}
|
||||
onClick={() => onSelect(stock)}
|
||||
onKeyDown={(event) => handleRowKeyDown(event, stock)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="h-11 px-3 align-middle">
|
||||
<p className="font-medium text-foreground">{signal.name}</p>
|
||||
<p className="font-medium text-foreground">{stock.name}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
{signal.ts_code}
|
||||
{stock.ts_code}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 align-middle">
|
||||
<Badge
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{stock.signals.map((signal) => (
|
||||
<Badge
|
||||
key={signal.category}
|
||||
className={categoryToneClass(signal.category)}
|
||||
variant="outline"
|
||||
>
|
||||
{getCategoryLabel(signal.category)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground">
|
||||
{getSignalJValue(signal.details)}
|
||||
{stock.signals.map((signal) => (
|
||||
<span key={signal.category} className="block">
|
||||
{getSignalJValue(signal.details)}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-3 text-right align-middle font-medium tabular-nums text-foreground">
|
||||
¥ {signal.close.toFixed(2)}
|
||||
¥ {stock.close.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
@@ -45,22 +45,31 @@ const selectedResult: SelectionResults = {
|
||||
run_id: "run-1",
|
||||
selected_stock_count: 1,
|
||||
signal_count: 2,
|
||||
signals_total: 2,
|
||||
signals: [
|
||||
stocks_total: 1,
|
||||
stocks: [
|
||||
{
|
||||
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: "平安银行",
|
||||
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: 13 },
|
||||
name: "平安银行",
|
||||
strategy: "zhixing_b1",
|
||||
target_trade_date: "2026-08-08",
|
||||
ts_code: "000001.SZ",
|
||||
},
|
||||
],
|
||||
strategy: "zhixing_b1",
|
||||
target_trade_date: "2026-08-08",
|
||||
ts_code: "000001.SZ",
|
||||
@@ -113,14 +122,15 @@ describe("SelectionResultsPage", () => {
|
||||
).not.toBeInTheDocument()
|
||||
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(within(table).getByText("回踩白线 B")).toBeInTheDocument()
|
||||
expect(within(table).getAllByText("12")).toHaveLength(2)
|
||||
expect(within(table).getAllByText("平安银行")).toHaveLength(2)
|
||||
expect(within(table).getByText("12")).toBeInTheDocument()
|
||||
expect(within(table).getByText("13")).toBeInTheDocument()
|
||||
expect(within(table).getByText("平安银行")).toBeInTheDocument()
|
||||
|
||||
const detailPanel = screen.getByRole("complementary", {
|
||||
name: "当前股票详情",
|
||||
})
|
||||
expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getByText("J 值")).toBeInTheDocument()
|
||||
expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2)
|
||||
expect(within(detailPanel).getByText("RSI")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -220,8 +230,8 @@ describe("SelectionResultsPage", () => {
|
||||
data: {
|
||||
...selectedResult,
|
||||
signal_count: 0,
|
||||
signals: [],
|
||||
signals_total: 0,
|
||||
stocks: [],
|
||||
stocks_total: 0,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
@@ -260,7 +270,7 @@ describe("SelectionResultsPage", () => {
|
||||
},
|
||||
],
|
||||
signal_count: 0,
|
||||
signals: [],
|
||||
stocks: [],
|
||||
status: "failed",
|
||||
},
|
||||
isError: false,
|
||||
@@ -293,6 +303,9 @@ describe("SelectionResultsPage", () => {
|
||||
|
||||
fireEvent.click(categoryTrigger)
|
||||
const oversoldOption = await screen.findByRole("option", { name: "超卖类" })
|
||||
const content = oversoldOption.closest('[data-slot="select-content"]')
|
||||
expect(content).toHaveAttribute("data-align-trigger", "false")
|
||||
expect(content?.parentElement).toHaveAttribute("data-align", "start")
|
||||
fireEvent.pointerDown(oversoldOption, { pointerType: "mouse" })
|
||||
fireEvent.click(oversoldOption)
|
||||
|
||||
@@ -316,18 +329,26 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
|
||||
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 },
|
||||
const extraStock = {
|
||||
...selectedResult.stocks[0],
|
||||
name: "招商银行",
|
||||
signals: [
|
||||
{
|
||||
...selectedResult.stocks[0].signals[0],
|
||||
category: "zhixing_b1_oversold_volume",
|
||||
details: { macd: 3.2 },
|
||||
name: "招商银行",
|
||||
ts_code: "600036.SH",
|
||||
},
|
||||
],
|
||||
ts_code: "600036.SH",
|
||||
}
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
signal_count: 3,
|
||||
signals: [...selectedResult.signals, extraSignal],
|
||||
stocks: [...selectedResult.stocks, extraStock],
|
||||
stocks_total: 2,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
@@ -335,7 +356,7 @@ describe("SelectionResultsPage", () => {
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText("搜索命中信号"), {
|
||||
fireEvent.change(screen.getByLabelText("搜索命中股票"), {
|
||||
target: { value: "招商" },
|
||||
})
|
||||
const table = screen.getByRole("table")
|
||||
@@ -347,7 +368,7 @@ describe("SelectionResultsPage", () => {
|
||||
).getByText("招商银行"),
|
||||
).toBeInTheDocument()
|
||||
|
||||
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[2]
|
||||
const expandButton = screen.getAllByRole("button", { name: "查看详情" })[1]
|
||||
fireEvent.click(expandButton)
|
||||
expect(expandButton).toHaveAttribute("aria-expanded", "true")
|
||||
expect(screen.getAllByText("MACD").length).toBeGreaterThan(1)
|
||||
@@ -355,17 +376,25 @@ describe("SelectionResultsPage", () => {
|
||||
})
|
||||
|
||||
it("navigates to another page when the result list is paginated", () => {
|
||||
const signals = Array.from({ length: 6 }, (_, index) => ({
|
||||
...selectedResult.signals[0],
|
||||
const stocks = Array.from({ length: 6 }, (_, index) => ({
|
||||
...selectedResult.stocks[0],
|
||||
name: `股票${index + 1}`,
|
||||
signals: [
|
||||
{
|
||||
...selectedResult.stocks[0].signals[0],
|
||||
name: `股票${index + 1}`,
|
||||
ts_code: `00000${index + 1}.SZ`,
|
||||
},
|
||||
],
|
||||
ts_code: `00000${index + 1}.SZ`,
|
||||
}))
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
signal_count: signals.length,
|
||||
signals,
|
||||
signals_total: signals.length,
|
||||
selected_stock_count: stocks.length,
|
||||
signal_count: stocks.length,
|
||||
stocks,
|
||||
stocks_total: stocks.length,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
@@ -422,7 +451,7 @@ describe("SelectionResultsPage", () => {
|
||||
],
|
||||
selected_stock_count: 0,
|
||||
signal_count: 0,
|
||||
signals: [],
|
||||
stocks: [],
|
||||
status: "failed",
|
||||
},
|
||||
isError: false,
|
||||
@@ -442,7 +471,7 @@ describe("SelectionResultsPage", () => {
|
||||
data: {
|
||||
...selectedResult,
|
||||
run_id: null,
|
||||
signals: [],
|
||||
stocks: [],
|
||||
status: "no_data",
|
||||
target_trade_date: "2026-08-08",
|
||||
},
|
||||
@@ -490,12 +519,12 @@ describe("SelectionResultsPage", () => {
|
||||
|
||||
it("continues polling a persisted running run after page reload", () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: { ...selectedResult, signals: [], status: "running" },
|
||||
data: { ...selectedResult, stocks: [], status: "running" },
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
useSelectionRun.mockReturnValue({
|
||||
data: { ...selectedResult, signals: [], status: "running" },
|
||||
data: { ...selectedResult, stocks: [], status: "running" },
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user