feat(web): 选股列表改为滚动加载并精简操作区

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yuxuanhui
2026-09-01 19:27:41 +08:00
parent c944b219d9
commit af1e1eaa66
10 changed files with 732 additions and 384 deletions
@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState, type RefObject } from "react"
import { useMemo, useRef, useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { Input } from "@/shared/ui/input"
import { Pagination } from "@/shared/ui/pagination"
import {
Select,
SelectContent,
@@ -12,12 +11,7 @@ import {
SelectValue,
} from "@/shared/ui/select"
import type {
SelectionResults,
SelectionSort,
SelectionStockResult,
} from "../api/selection.types"
import { ExecutionStatusTrigger } from "./execution-status-trigger"
import type { SelectionResults, SelectionSort } from "../api/selection.types"
import { SignalDetailPanel } from "./signal-detail-panel"
import { SignalRecordList } from "./signal-record-list"
import {
@@ -27,7 +21,6 @@ import {
type SignalCategoryFilter,
} from "./selection-presentation"
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
const SCORE_SORT_OPTIONS: ReadonlyArray<{
label: string
value: SelectionSort
@@ -38,17 +31,19 @@ const SCORE_SORT_OPTIONS: ReadonlyArray<{
]
interface SelectionResultsWorkbenchProps {
drawerOpen: boolean
onOpenExecutionStatus: () => void
hasNextPage: boolean
isFetchNextPageError: boolean
isFetchingNextPage: boolean
onLoadMore: () => void | Promise<unknown>
result: SelectionResults
triggerRef: RefObject<HTMLButtonElement | null>
}
export function SelectionResultsWorkbench({
drawerOpen,
onOpenExecutionStatus,
hasNextPage,
isFetchNextPageError,
isFetchingNextPage,
onLoadMore,
result,
triggerRef,
}: SelectionResultsWorkbenchProps) {
const search = useSearch({ from: "/_workspace/selection" })
const navigate = useNavigate({ from: "/selection" })
@@ -56,9 +51,7 @@ export function SelectionResultsWorkbench({
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 loadMoreRequestPending = useRef(false)
const visibleStocks = useMemo(() => {
const normalized = query.trim().toLowerCase()
@@ -71,26 +64,12 @@ export function SelectionResultsWorkbench({
})
}, [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])
visibleStocks.find((stock) => getStockKey(stock) === selectedKey) ??
visibleStocks[0]
function updateSearch(next: {
page?: number
pageSize?: number
search?: string
category?: SignalCategoryFilter
sort?: SelectionSort
@@ -99,30 +78,35 @@ export function SelectionResultsWorkbench({
}
function handleQueryChange(value: string) {
updateSearch({ page: 1, search: value })
updateSearch({ search: value })
}
function handleCategoryChange(value: SignalCategoryFilter) {
updateSearch({ category: value, page: 1 })
updateSearch({ category: value })
}
function handleSortChange(value: SelectionSort) {
updateSearch({ page: 1, sort: value })
updateSearch({ 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
function requestLoadMore() {
if (
loadMoreRequestPending.current ||
isFetchingNextPage ||
!hasNextPage ||
isFetchNextPageError
) {
return
}
loadMoreRequestPending.current = true
void Promise.resolve(onLoadMore()).finally(() => {
loadMoreRequestPending.current = false
})
}
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 min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,3fr)]">
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border/80 bg-card">
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
<Input
aria-label="搜索命中股票"
@@ -185,34 +169,22 @@ export function SelectionResultsWorkbench({
<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">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<SignalRecordList
expandedKeys={expandedKeys}
isFetchNextPageError={isFetchNextPageError}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={requestLoadMore}
onRetryLoadMore={() => {
loadMoreRequestPending.current = false
onLoadMore()
}}
onSelect={(stock) => setSelectedKey(getStockKey(stock))}
onToggleExpanded={handleToggleExpanded}
selectedKey={selectedStock ? getStockKey(selectedStock) : null}
stocks={currentStocks}
stocks={visibleStocks}
/>
</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} />
@@ -1,4 +1,3 @@
import { ChevronDown, ChevronUp } from "lucide-react"
import type { KeyboardEvent } from "react"
import { Badge } from "@/shared/ui/badge"
@@ -6,29 +5,32 @@ import { Button } from "@/shared/ui/button"
import { cn } from "@/shared/ui/utils"
import type { SelectionStockResult } from "../api/selection.types"
import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score"
import { PatternScoreSummary } from "./pattern-score"
import {
categoryToneClass,
getCategoryLabel,
getStockJValue,
getStockKey,
} from "./selection-presentation"
import { SignalDetails } from "./signal-details"
interface SignalRecordListProps {
stocks: SelectionStockResult[]
selectedKey: string | null
expandedKeys: ReadonlySet<string>
isFetchingNextPage: boolean
isFetchNextPageError: boolean
onSelect: (stock: SelectionStockResult) => void
onToggleExpanded: (stock: SelectionStockResult) => void
onLoadMore: () => void
onRetryLoadMore: () => void
}
export function SignalRecordList({
stocks,
selectedKey,
expandedKeys,
isFetchingNextPage,
isFetchNextPageError,
onSelect,
onToggleExpanded,
onLoadMore,
onRetryLoadMore,
}: SignalRecordListProps) {
function handleSelectKeyDown(
event: KeyboardEvent<HTMLButtonElement>,
@@ -48,99 +50,98 @@ export function SignalRecordList({
}
return (
<div className="min-h-0 flex-1 overflow-auto">
<div
aria-label="选股结果滚动区域"
className="min-h-0 flex-1 overflow-auto"
onScroll={(event) => {
const container = event.currentTarget
if (
container.scrollHeight -
container.scrollTop -
container.clientHeight <=
160
) {
onLoadMore()
}
}}
role="region"
tabIndex={0}
>
<div className="divide-y divide-border/60">
{stocks.map((stock) => {
const key = getStockKey(stock)
const selected = selectedKey === key
const expanded = expandedKeys.has(key)
return (
<article
key={key}
className={cn(
"space-y-1 px-2.5 py-1.5 transition-colors hover:bg-muted/50",
"px-2.5 py-1.5 transition-colors hover:bg-muted/50",
selected && "bg-muted/70",
)}
data-selected={selected}
>
<div className="flex items-start gap-2">
<button
className="min-h-11 min-w-0 flex-1 text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
onClick={() => onSelect(stock)}
onKeyDown={(event) => handleSelectKeyDown(event, stock)}
type="button"
>
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="font-medium text-foreground">
{stock.name}
</span>
<span className="font-mono text-[10px] text-muted-foreground">
{stock.ts_code}
</span>
<span className="ml-auto font-medium tabular-nums text-foreground">
¥ {stock.close.toFixed(2)}
</span>
<button
className="min-h-11 w-full text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
onClick={() => onSelect(stock)}
onKeyDown={(event) => handleSelectKeyDown(event, stock)}
type="button"
>
<span className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="font-medium text-foreground">
{stock.name}
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span className="flex flex-wrap gap-1">
{stock.signals.map((signal) => (
<Badge
key={signal.category}
className={categoryToneClass(signal.category)}
variant="outline"
>
{getCategoryLabel(signal.category)}
</Badge>
))}
</span>
<PatternScoreSummary score={stock.score} />
<span className="tabular-nums text-muted-foreground">
J&nbsp;
<strong className="font-semibold text-foreground">
{getStockJValue(stock)}
</strong>
</span>
<span className="font-mono text-[10px] text-muted-foreground">
{stock.ts_code}
</span>
</button>
<Button
aria-expanded={expanded}
className="min-h-11 shrink-0 gap-1 px-2 text-xs text-muted-foreground"
onClick={() => onToggleExpanded(stock)}
variant="ghost"
>
{expanded ? "收起详情" : "查看详情"}
{expanded ? (
<ChevronUp aria-hidden="true" />
) : (
<ChevronDown aria-hidden="true" />
)}
</Button>
</div>
{expanded ? (
<div className="space-y-3">
<section className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
图形相似度评分
</p>
<PatternScoreDetails score={stock.score} />
</section>
{stock.signals.map((signal) => (
<section key={signal.category} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
<span className="ml-auto font-medium tabular-nums text-foreground">
¥ {stock.close.toFixed(2)}
</span>
</span>
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span className="flex flex-wrap gap-1">
{stock.signals.map((signal) => (
<Badge
key={signal.category}
className={categoryToneClass(signal.category)}
variant="outline"
>
{getCategoryLabel(signal.category)}
</p>
<SignalDetails
className="sm:grid-cols-3"
details={signal.details}
/>
</section>
))}
</div>
) : null}
</Badge>
))}
</span>
<PatternScoreSummary score={stock.score} />
<span className="tabular-nums text-muted-foreground">
J&nbsp;
<strong className="font-semibold text-foreground">
{getStockJValue(stock)}
</strong>
</span>
</span>
</button>
</article>
)
})}
</div>
{isFetchingNextPage ? (
<div
aria-live="polite"
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
role="status"
>
正在加载更多股票…
</div>
) : null}
{isFetchNextPageError ? (
<div
className="flex flex-wrap items-center justify-center gap-2 border-t border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
role="alert"
>
<span>加载更多失败,已加载结果仍保留。</span>
<Button onClick={onRetryLoadMore} size="sm" variant="outline">
重试
</Button>
</div>
) : null}
</div>
)
}