Files
tick-stock-panel/frontend/src/components/DimensionMembersDialog.tsx
T
2026-07-18 00:49:28 +08:00

303 lines
14 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Building2, ChevronRight, RefreshCw, Search, Tags, Users, X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { boardTag } from '@/components/stock-table/primitives'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
export type DimensionKind = 'concept' | 'industry'
export interface DimensionMembersTarget {
kind: DimensionKind
value: string
/** 扩展字段完整标识,例如 ext_gn_ths.所属概念。 */
sourceField: string
date?: string
}
export function dimensionKindForSourceField(sourceField: string): DimensionKind | null {
const separator = sourceField.indexOf('.')
const field = (separator >= 0 ? sourceField.slice(separator + 1) : sourceField).trim().toLowerCase()
if (/(概念|题材)|(?:^|[_\s])(concept|theme)(?:$|[_\s])/i.test(field)) return 'concept'
if (/(行业|申万|中信)|(?:^|[_\s])(industry|sector)(?:$|[_\s])/i.test(field)) return 'industry'
return null
}
interface Props {
target: DimensionMembersTarget | null
onClose: () => void
onStockClick?: (symbol: string, name?: string) => void
}
interface ResolvedSource {
configId: string
field: string
}
type SortMode = 'change_desc' | 'change_asc' | 'amount_desc' | 'name'
function resolveSource(sourceField: string): ResolvedSource | null {
const separator = sourceField.indexOf('.')
if (separator <= 0 || separator === sourceField.length - 1) return null
return {
configId: sourceField.slice(0, separator),
field: sourceField.slice(separator + 1),
}
}
function symbolKeys(symbol: unknown): string[] {
const raw = String(symbol ?? '').trim().toUpperCase()
if (!raw) return []
return Array.from(new Set([raw, raw.replace(/\.\w+$/, '')]))
}
function finite(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function stockName(row: Record<string, any>): string {
return String(row.name ?? row['股票简称'] ?? row['名称'] ?? '')
}
function stockSymbol(row: Record<string, any>): string {
return String(row.symbol ?? row.code ?? row['股票代码'] ?? row['代码'] ?? '')
}
export function DimensionMembersDialog({ target, onClose, onStockClick }: Props) {
if (!target) return null
return (
<DimensionMembersDialogContent
key={`${target.sourceField}:${target.value}:${target.date ?? ''}`}
target={target}
onClose={onClose}
onStockClick={onStockClick}
/>
)
}
function DimensionMembersDialogContent({ target, onClose, onStockClick }: Omit<Props, 'target'> & { target: DimensionMembersTarget }) {
const source = useMemo(() => resolveSource(target.sourceField), [target.sourceField])
const [search, setSearch] = useState('')
const [sortMode, setSortMode] = useState<SortMode>('change_desc')
const listRef = useRef<HTMLDivElement>(null)
const membersQuery = useQuery({
queryKey: source ? QK.dimensionMembers(source.configId, source.field, target.value, target.date) : ['dimension-members-invalid'],
queryFn: () => api.dimensionMembers(source!.configId, {
field: source!.field,
value: target.value,
date: target.date,
limit: 10000,
}),
enabled: !!source,
staleTime: 5 * 60_000,
})
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
enabled: (membersQuery.data?.rows.length ?? 0) > 0,
staleTime: 60_000,
})
const marketMap = useMemo(() => {
const map = new Map<string, MarketSnapshotRow>()
for (const row of marketQuery.data?.rows ?? []) {
for (const key of symbolKeys(row.symbol)) map.set(key, row)
}
return map
}, [marketQuery.data?.rows])
const rows = useMemo(() => {
const seen = new Set<string>()
return (membersQuery.data?.rows ?? []).flatMap(member => {
const rawSymbol = stockSymbol(member)
const market = symbolKeys(rawSymbol).map(key => marketMap.get(key)).find(Boolean)
const symbol = String(market?.symbol ?? rawSymbol)
if (!symbol || seen.has(symbol)) return []
seen.add(symbol)
return [{
...member,
...market,
symbol,
name: market?.name ?? stockName(member),
}]
})
}, [marketMap, membersQuery.data?.rows])
const visibleRows = useMemo(() => {
const keyword = search.trim().toLowerCase()
const filtered = keyword
? rows.filter(row => `${row.symbol} ${row.name ?? ''}`.toLowerCase().includes(keyword))
: rows
return [...filtered].sort((a, b) => {
if (sortMode === 'name') return String(a.name ?? a.symbol).localeCompare(String(b.name ?? b.symbol), 'zh-CN')
if (sortMode === 'amount_desc') return (finite(b.amount) ?? -Infinity) - (finite(a.amount) ?? -Infinity)
const av = finite(a.change_pct)
const bv = finite(b.change_pct)
if (sortMode === 'change_asc') return (av ?? Infinity) - (bv ?? Infinity)
return (bv ?? -Infinity) - (av ?? -Infinity)
})
}, [rows, search, sortMode])
const stats = useMemo(() => {
const changes = rows.map(row => finite(row.change_pct)).filter((value): value is number => value != null)
return {
up: changes.filter(value => value > 0).length,
down: changes.filter(value => value < 0).length,
flat: rows.length - changes.filter(value => value !== 0).length,
average: changes.length ? changes.reduce((sum, value) => sum + value, 0) / changes.length : null,
}
}, [rows])
const rowVirtualizer = useVirtualizer({
count: visibleRows.length,
getScrollElement: () => listRef.current,
estimateSize: () => 54,
getItemKey: index => visibleRows[index]?.symbol ?? index,
overscan: 8,
})
useEffect(() => {
listRef.current?.scrollTo({ top: 0 })
}, [search, sortMode])
const accent = target.kind === 'concept'
? { icon: Tags, badge: '概念', iconCls: 'text-orange-700 dark:text-orange-300', badgeCls: 'bg-orange-500/10 text-orange-700 dark:text-orange-300' }
: { icon: Building2, badge: '行业', iconCls: 'text-sky-700 dark:text-sky-300', badgeCls: 'bg-sky-500/10 text-sky-700 dark:text-sky-300' }
const AccentIcon = accent.icon
const titleId = 'dimension-members-title'
const total = membersQuery.data?.total ?? 0
return (
<Modal
onClose={onClose}
labelledBy={titleId}
panelClassName="flex h-[86vh] max-h-[760px] w-[94vw] max-w-4xl flex-col overflow-hidden rounded-card border border-border bg-base shadow-2xl"
>
<div className="flex shrink-0 items-center gap-3 border-b border-border px-4 py-3">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-elevated ${accent.iconCls}`}>
<AccentIcon className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 id={titleId} className="truncate text-sm font-semibold text-foreground">{target.value}</h2>
<span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium ${accent.badgeCls}`}>{accent.badge}</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted">
<span>{membersQuery.data?.label ?? source?.configId ?? '扩展数据'}</span>
{membersQuery.data?.date && <span>{membersQuery.data.date}</span>}
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<span className="inline-flex items-center gap-1 text-xs text-secondary">
<Users className="h-3.5 w-3.5 text-muted" />
{membersQuery.isLoading ? '—' : total}
</span>
<button onClick={onClose} className="inline-flex h-7 w-7 items-center justify-center rounded text-muted hover:bg-elevated hover:text-foreground" title="关闭">
<X className="h-4 w-4" />
</button>
</div>
</div>
{!source ? (
<div className="grid min-h-64 place-items-center px-6 text-sm text-danger">扩展字段格式无效</div>
) : membersQuery.isLoading ? (
<div className="grid min-h-64 place-items-center text-muted"><RefreshCw className="h-5 w-5 animate-spin" /></div>
) : membersQuery.isError ? (
<div className="grid min-h-64 place-items-center px-6 text-center text-sm text-danger">{String((membersQuery.error as Error).message)}</div>
) : (
<>
<div className="grid shrink-0 grid-cols-4 divide-x divide-border border-b border-border bg-surface/50">
<Summary label="上涨" value={stats.up} className="text-bull" />
<Summary label="下跌" value={stats.down} className="text-bear" />
<Summary label="平盘/待更新" value={stats.flat} className="text-secondary" />
<Summary label="平均涨跌" value={fmtPct(stats.average)} className={priceColorClass(stats.average)} />
</div>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
value={search}
onChange={event => setSearch(event.target.value)}
placeholder="搜索代码或名称"
className="h-8 w-full rounded-input border border-border bg-surface pl-8 pr-3 text-xs text-foreground placeholder:text-muted focus:border-accent/60 focus:outline-none"
/>
</div>
<select
value={sortMode}
onChange={event => setSortMode(event.target.value as SortMode)}
className="h-8 rounded-input border border-border bg-surface px-2 text-xs text-secondary focus:border-accent/60 focus:outline-none"
aria-label="排序方式"
>
<option value="change_desc">涨幅从高到低</option>
<option value="change_asc">涨幅从低到高</option>
<option value="amount_desc">成交额从高到低</option>
<option value="name">名称排序</option>
</select>
</div>
<div className="grid shrink-0 grid-cols-[minmax(132px,1fr)_74px_74px_18px] border-b border-border bg-elevated/60 px-4 py-2 text-[10px] font-medium text-muted md:grid-cols-[minmax(180px,1fr)_90px_84px_88px_100px_18px]">
<span>股票</span><span className="text-right">现价</span><span className="text-right">涨跌幅</span>
<span className="hidden text-right md:block">换手率</span><span className="hidden text-right md:block">成交额</span><span />
</div>
{visibleRows.length === 0 ? (
<div className="grid min-h-56 place-items-center text-sm text-muted">{search ? '没有匹配的股票' : '暂无成分股'}</div>
) : (
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="relative w-full" style={{ height: rowVirtualizer.getTotalSize() }}>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const row = visibleRows[virtualRow.index]
const board = boardTag(row.symbol)
return (
<button
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
onClick={() => onStockClick?.(row.symbol, row.name)}
disabled={!onStockClick}
className="absolute left-0 top-0 grid min-h-[54px] w-full grid-cols-[minmax(132px,1fr)_74px_74px_18px] items-center border-b border-border/60 px-4 text-left text-xs transition-colors hover:bg-elevated/50 disabled:cursor-default md:grid-cols-[minmax(180px,1fr)_90px_84px_88px_100px_18px]"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<span className="flex min-w-0 items-center gap-2">
{board && <span className={`inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded border text-[9px] font-bold ${board.color}`}>{board.label}</span>}
<span className="min-w-0">
<span className="block truncate font-medium text-foreground">{row.name || row.symbol}</span>
<span className="block font-mono text-[10px] text-muted">{row.symbol}</span>
</span>
</span>
<span className="text-right tabular-nums text-secondary">{fmtPrice(finite(row.close))}</span>
<span className={`text-right tabular-nums font-medium ${priceColorClass(finite(row.change_pct))}`}>{fmtPct(finite(row.change_pct))}</span>
<span className="hidden text-right tabular-nums text-secondary md:block">{finite(row.turnover_rate) != null ? `${finite(row.turnover_rate)!.toFixed(2)}%` : '—'}</span>
<span className="hidden text-right tabular-nums text-secondary md:block">{fmtBigNum(finite(row.amount))}</span>
<ChevronRight className="h-3.5 w-3.5 text-muted" />
</button>
)
})}
</div>
</div>
)}
{total > rows.length && (
<div className="shrink-0 border-t border-border px-4 py-2 text-center text-[10px] text-muted">显示前 {rows.length} / {total} </div>
)}
</>
)}
</Modal>
)
}
function Summary({ label, value, className }: { label: string; value: string | number; className: string }) {
return (
<div className="flex items-baseline justify-center gap-1.5 px-2 py-2.5">
<span className="text-[10px] text-muted">{label}</span>
<span className={`text-xs font-semibold tabular-nums ${className}`}>{value}</span>
</div>
)
}