feat(web): 选股列表改为滚动加载并精简操作区
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
<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
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user