2026-08-10 14:50:56 +08:00
|
|
|
import { useEffect, useMemo, useState, type RefObject } from "react"
|
2026-08-10 10:07:55 +08:00
|
|
|
import { useNavigate, useSearch } from "@tanstack/react-router"
|
|
|
|
|
|
|
|
|
|
import { Input } from "@/shared/ui/input"
|
|
|
|
|
import { Pagination } from "@/shared/ui/pagination"
|
2026-08-10 14:50:56 +08:00
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectGroup,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/shared/ui/select"
|
2026-08-10 10:07:55 +08:00
|
|
|
|
2026-08-28 11:39:05 +08:00
|
|
|
import type {
|
|
|
|
|
SelectionResults,
|
2026-08-31 16:14:16 +08:00
|
|
|
SelectionSort,
|
2026-08-28 11:39:05 +08:00
|
|
|
SelectionStockResult,
|
|
|
|
|
} from "../api/selection.types"
|
2026-08-10 14:50:56 +08:00
|
|
|
import { ExecutionStatusTrigger } from "./execution-status-trigger"
|
2026-08-10 10:07:55 +08:00
|
|
|
import { SignalDetailPanel } from "./signal-detail-panel"
|
|
|
|
|
import { SignalRecordList } from "./signal-record-list"
|
|
|
|
|
import {
|
2026-08-28 11:39:05 +08:00
|
|
|
getStockKey,
|
2026-08-10 10:07:55 +08:00
|
|
|
matchesCategory,
|
|
|
|
|
signalCategoryOptions,
|
|
|
|
|
type SignalCategoryFilter,
|
|
|
|
|
} from "./selection-presentation"
|
|
|
|
|
|
2026-08-10 11:09:23 +08:00
|
|
|
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
|
2026-08-31 16:14:16 +08:00
|
|
|
const SCORE_SORT_OPTIONS: ReadonlyArray<{
|
|
|
|
|
label: string
|
|
|
|
|
value: SelectionSort
|
|
|
|
|
}> = [
|
|
|
|
|
{ label: "按股票代码", value: "code" },
|
|
|
|
|
{ label: "评分从高到低", value: "score_desc" },
|
|
|
|
|
{ label: "评分从低到高", value: "score_asc" },
|
|
|
|
|
]
|
2026-08-10 10:07:55 +08:00
|
|
|
|
|
|
|
|
interface SelectionResultsWorkbenchProps {
|
2026-08-10 14:50:56 +08:00
|
|
|
drawerOpen: boolean
|
|
|
|
|
onOpenExecutionStatus: () => void
|
2026-08-10 10:07:55 +08:00
|
|
|
result: SelectionResults
|
2026-08-10 14:50:56 +08:00
|
|
|
triggerRef: RefObject<HTMLButtonElement | null>
|
2026-08-10 10:07:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SelectionResultsWorkbench({
|
2026-08-10 14:50:56 +08:00
|
|
|
drawerOpen,
|
|
|
|
|
onOpenExecutionStatus,
|
2026-08-10 10:07:55 +08:00
|
|
|
result,
|
2026-08-10 14:50:56 +08:00
|
|
|
triggerRef,
|
2026-08-10 10:07:55 +08:00
|
|
|
}: SelectionResultsWorkbenchProps) {
|
|
|
|
|
const search = useSearch({ from: "/_workspace/selection" })
|
|
|
|
|
const navigate = useNavigate({ from: "/selection" })
|
2026-08-10 11:09:23 +08:00
|
|
|
const query = search.search ?? ""
|
|
|
|
|
const category = search.category ?? "all"
|
2026-08-31 16:14:16 +08:00
|
|
|
const sort = search.sort ?? "code"
|
2026-08-10 10:07:55 +08:00
|
|
|
const [selectedKey, setSelectedKey] = useState<string | null>(null)
|
|
|
|
|
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
|
|
|
|
|
new Set(),
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-28 11:39:05 +08:00
|
|
|
const visibleStocks = useMemo(() => {
|
2026-08-10 10:07:55 +08:00
|
|
|
const normalized = query.trim().toLowerCase()
|
2026-08-28 11:39:05 +08:00
|
|
|
return result.stocks.filter((stock) => {
|
2026-08-10 10:07:55 +08:00
|
|
|
const matchesQuery =
|
|
|
|
|
normalized.length === 0 ||
|
2026-08-28 11:39:05 +08:00
|
|
|
stock.name.toLowerCase().includes(normalized) ||
|
|
|
|
|
stock.ts_code.toLowerCase().includes(normalized)
|
|
|
|
|
return matchesQuery && matchesCategory(stock, category)
|
2026-08-10 10:07:55 +08:00
|
|
|
})
|
2026-08-28 11:39:05 +08:00
|
|
|
}, [category, query, result.stocks])
|
2026-08-10 10:07:55 +08:00
|
|
|
|
|
|
|
|
const page = search.page
|
|
|
|
|
const pageSize = search.pageSize
|
2026-08-28 11:39:05 +08:00
|
|
|
const stocksTotal = result.stocks_total ?? result.selected_stock_count
|
|
|
|
|
const pageCount = Math.max(1, Math.ceil(stocksTotal / pageSize))
|
2026-08-10 10:07:55 +08:00
|
|
|
const currentPage = Math.min(page, pageCount)
|
2026-08-28 11:39:05 +08:00
|
|
|
const currentStocks = visibleStocks
|
|
|
|
|
const selectedStock =
|
|
|
|
|
currentStocks.find((stock) => getStockKey(stock) === selectedKey) ??
|
|
|
|
|
currentStocks[0]
|
2026-08-10 10:07:55 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (page > pageCount) {
|
|
|
|
|
void navigate({
|
|
|
|
|
search: (previous) => ({ ...previous, page: pageCount }),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}, [navigate, page, pageCount])
|
|
|
|
|
|
2026-08-10 11:09:23 +08:00
|
|
|
function updateSearch(next: {
|
|
|
|
|
page?: number
|
|
|
|
|
pageSize?: number
|
|
|
|
|
search?: string
|
|
|
|
|
category?: SignalCategoryFilter
|
2026-08-31 16:14:16 +08:00
|
|
|
sort?: SelectionSort
|
2026-08-10 11:09:23 +08:00
|
|
|
}) {
|
2026-08-10 10:07:55 +08:00
|
|
|
void navigate({ search: (previous) => ({ ...previous, ...next }) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleQueryChange(value: string) {
|
2026-08-10 11:09:23 +08:00
|
|
|
updateSearch({ page: 1, search: value })
|
2026-08-10 10:07:55 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleCategoryChange(value: SignalCategoryFilter) {
|
2026-08-10 11:09:23 +08:00
|
|
|
updateSearch({ category: value, page: 1 })
|
2026-08-10 10:07:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-31 16:14:16 +08:00
|
|
|
function handleSortChange(value: SelectionSort) {
|
|
|
|
|
updateSearch({ page: 1, sort: value })
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 11:39:05 +08:00
|
|
|
function handleToggleExpanded(stock: SelectionStockResult) {
|
|
|
|
|
const key = getStockKey(stock)
|
2026-08-10 10:07:55 +08:00
|
|
|
setExpandedKeys((previous) => {
|
|
|
|
|
const next = new Set(previous)
|
|
|
|
|
if (next.has(key)) next.delete(key)
|
|
|
|
|
else next.add(key)
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-09-01 13:37:19 +08:00
|
|
|
<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">
|
2026-08-10 16:58:16 +08:00
|
|
|
<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">
|
2026-08-10 14:50:56 +08:00
|
|
|
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
|
2026-08-10 10:07:55 +08:00
|
|
|
<Input
|
2026-08-28 11:39:05 +08:00
|
|
|
aria-label="搜索命中股票"
|
2026-08-10 14:50:56 +08:00
|
|
|
className="h-11 min-w-0 flex-1 bg-background text-sm sm:max-w-[260px] md:h-8"
|
2026-08-10 10:07:55 +08:00
|
|
|
onChange={(event) => handleQueryChange(event.target.value)}
|
|
|
|
|
placeholder="搜索股票名称或代码"
|
|
|
|
|
type="search"
|
|
|
|
|
value={query}
|
|
|
|
|
/>
|
2026-08-10 14:50:56 +08:00
|
|
|
<Select
|
|
|
|
|
items={signalCategoryOptions}
|
|
|
|
|
onValueChange={(value) => {
|
|
|
|
|
if (typeof value === "string") {
|
|
|
|
|
handleCategoryChange(value as SignalCategoryFilter)
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-08-10 10:07:55 +08:00
|
|
|
value={category}
|
|
|
|
|
>
|
2026-08-10 14:50:56 +08:00
|
|
|
<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>
|
2026-08-28 11:39:05 +08:00
|
|
|
<SelectContent align="start" alignItemWithTrigger={false}>
|
2026-08-10 14:50:56 +08:00
|
|
|
<SelectGroup>
|
|
|
|
|
{signalCategoryOptions.map((option) => (
|
|
|
|
|
<SelectItem key={option.value} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectGroup>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2026-08-31 16:14:16 +08:00
|
|
|
<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>
|
2026-08-10 14:50:56 +08:00
|
|
|
<span className="text-xs tabular-nums text-muted-foreground sm:ml-auto">
|
2026-08-28 11:39:05 +08:00
|
|
|
筛选结果 {stocksTotal} 只
|
2026-08-10 10:07:55 +08:00
|
|
|
</span>
|
2026-08-10 14:50:56 +08:00
|
|
|
<ExecutionStatusTrigger
|
|
|
|
|
drawerOpen={drawerOpen}
|
|
|
|
|
onOpen={onOpenExecutionStatus}
|
|
|
|
|
result={result}
|
|
|
|
|
triggerRef={triggerRef}
|
|
|
|
|
/>
|
2026-08-10 10:07:55 +08:00
|
|
|
</div>
|
|
|
|
|
|
2026-08-10 13:42:53 +08:00
|
|
|
<div className="flex min-h-0 min-w-0 flex-col md:flex-1 md:overflow-hidden">
|
2026-08-10 10:07:55 +08:00
|
|
|
<SignalRecordList
|
|
|
|
|
expandedKeys={expandedKeys}
|
2026-08-28 11:39:05 +08:00
|
|
|
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
|
2026-08-10 10:07:55 +08:00
|
|
|
onToggleExpanded={handleToggleExpanded}
|
2026-09-01 14:57:27 +08:00
|
|
|
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
|
2026-08-28 11:39:05 +08:00
|
|
|
stocks={currentStocks}
|
2026-08-10 10:07:55 +08:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Pagination
|
|
|
|
|
onPageChange={(nextPage) => updateSearch({ page: nextPage })}
|
|
|
|
|
onPageSizeChange={(nextPageSize) =>
|
|
|
|
|
updateSearch({ page: 1, pageSize: nextPageSize })
|
|
|
|
|
}
|
|
|
|
|
page={currentPage}
|
|
|
|
|
pageSize={pageSize}
|
|
|
|
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
2026-08-28 11:39:05 +08:00
|
|
|
total={stocksTotal}
|
2026-08-10 10:07:55 +08:00
|
|
|
/>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-08-28 11:39:05 +08:00
|
|
|
<SignalDetailPanel stock={selectedStock} />
|
2026-08-10 10:07:55 +08:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|