import { useEffect, useMemo, useRef, useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useVirtualizer } from '@tanstack/react-virtual' import { Link } from 'react-router-dom' import { createChart, LineStyle, type IChartApi, type ISeriesApi, type LineData, type Time, } from 'lightweight-charts' import { Activity, Building2, ChevronRight, Database, RefreshCw, Search, Tags, Users, X } from 'lucide-react' import { Modal } from '@/components/Modal' import { boardTag } from '@/components/stock-table/primitives' import { api, type DimensionIntradayPoint, type MarketSnapshotRow } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { fmtBigNum, fmtPct, fmtPrice, priceColorClass } from '@/lib/format' import { useChartTheme } from '@/lib/theme' 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 { return String(row.name ?? row['股票简称'] ?? row['名称'] ?? '') } function stockSymbol(row: Record): string { return String(row.symbol ?? row.code ?? row['股票代码'] ?? row['代码'] ?? '') } export function DimensionMembersDialog({ target, onClose, onStockClick }: Props) { if (!target) return null return ( ) } function DimensionMembersDialogContent({ target, onClose, onStockClick }: Omit & { target: DimensionMembersTarget }) { const source = useMemo(() => resolveSource(target.sourceField), [target.sourceField]) const [search, setSearch] = useState('') const [sortMode, setSortMode] = useState('change_desc') const listRef = useRef(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() 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() 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 (

{target.value}

{accent.badge}
{membersQuery.data?.label ?? source?.configId ?? '扩展数据'} {membersQuery.data?.date && {membersQuery.data.date}}
{membersQuery.isLoading ? '—' : total}
{!source ? (
扩展字段格式无效
) : membersQuery.isLoading ? (
) : membersQuery.isError ? (
{String((membersQuery.error as Error).message)}
) : ( <>
{source && ( )}
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" />
股票现价涨跌幅 换手率成交额
{visibleRows.length === 0 ? (
{search ? '没有匹配的股票' : '暂无成分股'}
) : (
{rowVirtualizer.getVirtualItems().map(virtualRow => { const row = visibleRows[virtualRow.index] const board = boardTag(row.symbol) return ( ) })}
)} {total > rows.length && (
显示前 {rows.length} / {total} 只
)} )}
) } function Summary({ label, value, className }: { label: string; value: string | number; className: string }) { return (
{label} {value}
) } // --------------------------------------------------------------------------- // 板块分时 (等权): 点击触发 + 60s 轮询续期, 不预计算 // --------------------------------------------------------------------------- const INTRADAY_SECTOR_COLOR: Record = { concept: '#F97316', industry: '#0EA5E9', } const INTRADAY_MARKET_COLOR = '#94A3B8' // 横轴伪时间戳基点 (2020-01-01 UTC), 每点 +60s 保持均匀间距 const INTRADAY_BASE_TS = 1577836800 function lastNonNull(points: DimensionIntradayPoint[], key: 'sector' | 'market'): number | null { for (let i = points.length - 1; i >= 0; i--) { const value = points[i]?.[key] if (value != null) return value } return null } function DimensionIntradaySection({ configId, field, value, date, kind }: { configId: string field: string value: string date?: string kind: DimensionKind }) { const query = useQuery({ queryKey: QK.dimensionIntraday(configId, field, value, date), queryFn: () => api.dimensionIntraday(configId, { field, value, date }), staleTime: 15_000, refetchInterval: 60_000, }) const data = query.data return (
分时走势 · 等权 {data?.member_count != null && data.members_with_minute != null && ( {data.members_with_minute}/{data.member_count}只 )} {data?.basis && data.basis !== 'prev_close' && ( 基准:当日首价 )} {data?.status === 'ok' && (
板块 {fmtPct(lastNonNull(data.points, 'sector'))} 全市场 {fmtPct(lastNonNull(data.points, 'market'))} {data.date && {data.date}}
)}
{query.isLoading ? (
) : query.isError ? (
分时加载失败:{String((query.error as Error).message)}
) : data?.status === 'no_data' ? (

分钟数据未落盘, 暂无分时走势

需 TickFlow Pro+ 盘后分钟同步 / Expert 盘中增量, 或自定义分钟源

前往数据页 →
) : !data || data.status === 'empty' || data.points.length < 2 ? (
{data?.reason === 'no_member_bars' ? '成分股当日无分钟数据 (ETF 等标的无分钟落盘)' : '暂无成分股分时数据'}
) : ( )}
) } function IntradayChart({ points, kind }: { points: DimensionIntradayPoint[]; kind: DimensionKind }) { const containerRef = useRef(null) const chartRef = useRef(null) const sectorRef = useRef | null>(null) const marketRef = useRef | null>(null) const ct = useChartTheme() const ctRef = useRef(ct) ctRef.current = ct // v4 不支持字符串时间: 用均匀伪时间戳作横轴, 标签经 formatter 映射回 HH:MM const labelsRef = useRef([]) const labelAt = (time: number) => labelsRef.current[time - INTRADAY_BASE_TS] ?? '' useEffect(() => { const el = containerRef.current if (!el) return const chart = createChart(el, { width: el.clientWidth, height: 132, layout: { // 关闭 TV 角标 (licence 归属改由 README 技术栈外链承担) attributionLogo: false, background: { color: 'transparent' }, textColor: ctRef.current.text, fontFamily: 'JetBrains Mono, monospace', fontSize: 10, }, grid: { vertLines: { color: ctRef.current.grid }, horzLines: { color: ctRef.current.grid }, }, rightPriceScale: { borderColor: ctRef.current.border, scaleMargins: { top: 0.12, bottom: 0.04 } }, timeScale: { borderColor: ctRef.current.border, rightOffset: 2, barSpacing: 4, tickMarkFormatter: (time: number) => labelAt(time), }, localization: { timeFormatter: (time: number) => labelAt(time), }, crosshair: { vertLine: { labelVisible: false }, horzLine: { labelVisible: true }, }, handleScroll: false, handleScale: false, }) const sector = chart.addLineSeries({ color: INTRADAY_SECTOR_COLOR[kind], lineWidth: 2, priceLineVisible: false, lastValueVisible: true, priceFormat: { type: 'custom', formatter: (v: number) => `${(v * 100).toFixed(2)}%`, minMove: 0.0001 }, crosshairMarkerRadius: 3, }) const market = chart.addLineSeries({ color: INTRADAY_MARKET_COLOR, lineWidth: 1, lineStyle: LineStyle.Dashed, priceLineVisible: false, lastValueVisible: false, crosshairMarkerRadius: 2, }) sector.createPriceLine({ price: 0, color: ctRef.current.border, lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: false, }) chartRef.current = chart sectorRef.current = sector marketRef.current = market const observer = new ResizeObserver(() => { chart.applyOptions({ width: el.clientWidth }) }) observer.observe(el) return () => { observer.disconnect() chart.remove() chartRef.current = null sectorRef.current = null marketRef.current = null } }, [kind]) useEffect(() => { chartRef.current?.applyOptions({ layout: { textColor: ct.text }, grid: { vertLines: { color: ct.grid }, horzLines: { color: ct.grid } }, rightPriceScale: { borderColor: ct.border }, timeScale: { borderColor: ct.border }, }) }, [ct]) useEffect(() => { const sectorSeries = sectorRef.current const marketSeries = marketRef.current if (!sectorSeries || !marketSeries) return labelsRef.current = points.map(p => p.time) const toData = (key: 'sector' | 'market'): LineData[] => points .map((p, i) => ({ time: (INTRADAY_BASE_TS + i * 60) as Time, value: p[key] })) .filter((d): d is LineData => d.value != null) sectorSeries.setData(toData('sector')) marketSeries.setData(toData('market')) chartRef.current?.timeScale().fitContent() }, [points]) return (
) }