Files
zhixing-system/zhixing-web/src/features/selection/components/selection-results-workbench.tsx
T
2026-09-01 14:57:27 +08:00

222 lines
7.3 KiB
TypeScript

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,
SelectionSort,
SelectionStockResult,
} from "../api/selection.types"
import { ExecutionStatusTrigger } from "./execution-status-trigger"
import { SignalDetailPanel } from "./signal-detail-panel"
import { SignalRecordList } from "./signal-record-list"
import {
getStockKey,
matchesCategory,
signalCategoryOptions,
type SignalCategoryFilter,
} from "./selection-presentation"
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
const SCORE_SORT_OPTIONS: ReadonlyArray<{
label: string
value: SelectionSort
}> = [
{ label: "按股票代码", value: "code" },
{ label: "评分从高到低", value: "score_desc" },
{ label: "评分从低到高", value: "score_asc" },
]
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" })
const query = search.search ?? ""
const category = search.category ?? "all"
const sort = search.sort ?? "code"
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
new Set(),
)
const visibleStocks = useMemo(() => {
const normalized = query.trim().toLowerCase()
return result.stocks.filter((stock) => {
const matchesQuery =
normalized.length === 0 ||
stock.name.toLowerCase().includes(normalized) ||
stock.ts_code.toLowerCase().includes(normalized)
return matchesQuery && matchesCategory(stock, category)
})
}, [category, query, result.stocks])
const page = search.page
const pageSize = search.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 currentStocks = visibleStocks
const selectedStock =
currentStocks.find((stock) => getStockKey(stock) === selectedKey) ??
currentStocks[0]
useEffect(() => {
if (page > pageCount) {
void navigate({
search: (previous) => ({ ...previous, page: pageCount }),
})
}
}, [navigate, page, pageCount])
function updateSearch(next: {
page?: number
pageSize?: number
search?: string
category?: SignalCategoryFilter
sort?: SelectionSort
}) {
void navigate({ search: (previous) => ({ ...previous, ...next }) })
}
function handleQueryChange(value: string) {
updateSearch({ page: 1, search: value })
}
function handleCategoryChange(value: SignalCategoryFilter) {
updateSearch({ category: value, page: 1 })
}
function handleSortChange(value: SelectionSort) {
updateSearch({ page: 1, sort: value })
}
function handleToggleExpanded(stock: SelectionStockResult) {
const key = getStockKey(stock)
setExpandedKeys((previous) => {
const next = new Set(previous)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
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)_minmax(0,3fr)] md:overflow-hidden">
<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="搜索命中股票"
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
items={signalCategoryOptions}
onValueChange={(value) => {
if (typeof value === "string") {
handleCategoryChange(value as SignalCategoryFilter)
}
}}
value={category}
>
<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 align="start" alignItemWithTrigger={false}>
<SelectGroup>
{signalCategoryOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select
items={SCORE_SORT_OPTIONS}
onValueChange={(value) => {
if (typeof value === "string") {
handleSortChange(value as SelectionSort)
}
}}
value={sort}
>
<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 align="start" alignItemWithTrigger={false}>
<SelectGroup>
{SCORE_SORT_OPTIONS.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">
筛选结果 {stocksTotal} 只
</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">
<SignalRecordList
expandedKeys={expandedKeys}
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
onToggleExpanded={handleToggleExpanded}
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
stocks={currentStocks}
/>
</div>
<Pagination
onPageChange={(nextPage) => updateSearch({ page: nextPage })}
onPageSizeChange={(nextPageSize) =>
updateSearch({ page: 1, pageSize: nextPageSize })
}
page={currentPage}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={stocksTotal}
/>
</section>
<SignalDetailPanel stock={selectedStock} />
</div>
)
}