import { useMemo, useState, type ReactNode } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { AnimatePresence } from 'framer-motion' import { Activity, Crown, Layers3, RefreshCw, Repeat, Search, Settings2, TrendingDown, TrendingUp, } from 'lucide-react' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { AnalysisConfigDialog, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared' import { StockPreviewDialog } from '@/components/StockPreviewDialog' import { RpsRotationDialog } from '@/components/RpsRotationDialog' import { api, type MarketSnapshotRow } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { storage } from '@/lib/storage' import { fmtBigNum, fmtPct, priceColorClass } from '@/lib/format' import { cn } from '@/lib/cn' import { resolveDimension, type DimensionGroup, type StockRow } from '@/lib/analysis-adapter' const KEYWORDS = ['concept', '概念', 'theme', '题材', '板块'] const CANDIDATE_FIELDS = ['concept', '概念', 'theme', '题材', '板块', 'concept_name', '概念名称'] const PAGE_LIMIT = 12000 const MAX_RENDERED_CONCEPTS = 120 const MAX_RENDERED_STOCKS = 160 type SortMode = 'heat' | 'avgPct' | 'leader' | 'amount' | 'down' interface EnrichedStock extends MarketSnapshotRow { leaderScore: number leaderParts: { momentum: number turnover: number amount: number cap: number volume: number boards: number } } interface ConceptStat { key: string stocks: EnrichedStock[] count: number avgPct: number | null medianPct: number | null upCount: number downCount: number flatCount: number upRate: number strongCount: number weakCount: number totalAmount: number avgTurnover: number | null avgVolRatio: number | null leader: EnrichedStock | null heatScore: number riskScore: number } function loadConfig(): AnalysisFieldConfig { return storage.conceptAnalysisConfig.get({}) as AnalysisFieldConfig } function saveConfig(c: AnalysisFieldConfig) { storage.conceptAnalysisConfig.set(c) } function pickBestConfig( configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[], ): string { let best = '' let bestScore = 0 for (const c of configs) { const haystack = [c.id, c.label, c.description ?? '', ...c.fields.flatMap(f => [f.name, f.label])].join(' ').toLowerCase() const score = KEYWORDS.reduce((n, k) => n + (haystack.includes(k) ? 1 : 0), 0) if (score > bestScore) { bestScore = score best = c.id } } return best } function symbolKeys(symbol: unknown): string[] { const raw = String(symbol ?? '').trim() if (!raw) return [] const plain = raw.replace(/\.\w+$/, '') return Array.from(new Set([raw, plain])) } function buildMarketMap(rows: MarketSnapshotRow[]) { const map = new Map() for (const r of rows) { for (const key of symbolKeys(r.symbol)) map.set(key, r) } return map } function clamp01(v: number) { if (!Number.isFinite(v)) return 0 return Math.max(0, Math.min(1, v)) } function num(v: unknown): number | null { return typeof v === 'number' && Number.isFinite(v) ? v : null } function avg(values: number[]) { return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null } function median(values: number[]) { if (!values.length) return null const sorted = [...values].sort((a, b) => a - b) const mid = Math.floor(sorted.length / 2) return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 } function leaderScore(stock: MarketSnapshotRow) { const pct = num(stock.change_pct) ?? 0 const turnover = num(stock.turnover_rate) ?? 0 const amount = num(stock.amount) ?? 0 const cap = num(stock.float_market_cap) ?? num(stock.market_cap) ?? 0 const volRatio = num(stock.vol_ratio_5d) ?? 1 const boards = num(stock.consecutive_limit_ups) ?? 0 const parts = { momentum: clamp01((pct + 0.02) / 0.12), turnover: clamp01(Math.log1p(Math.max(turnover, 0)) / Math.log1p(30)), amount: clamp01(Math.log1p(Math.max(amount, 0)) / Math.log1p(20_000_000_000)), cap: clamp01(Math.log1p(Math.max(cap, 0)) / Math.log1p(300_000_000_000)), volume: clamp01((volRatio - 1) / 4), boards: clamp01(boards / 5), } const score = ( parts.momentum * 0.35 + parts.turnover * 0.22 + parts.amount * 0.18 + parts.cap * 0.15 + parts.volume * 0.07 + parts.boards * 0.03 ) * 100 return { score, parts } } function enrichStock(stock: StockRow, marketMap: Map): EnrichedStock { const market = symbolKeys(stock.symbol ?? stock.code).map(k => marketMap.get(k)).find(Boolean) ?? {} const merged = { ...stock, ...market } as MarketSnapshotRow & StockRow const ls = leaderScore(merged) return { ...merged, symbol: String(merged.symbol ?? stock.symbol ?? stock.code ?? ''), name: merged.name ?? String(stock.name ?? stock['股票简称'] ?? ''), leaderScore: ls.score, leaderParts: ls.parts, } } function calcConceptStat(group: DimensionGroup, marketMap: Map): ConceptStat { const seen = new Set() const stocks = group.stocks .map(s => enrichStock(s, marketMap)) .filter(s => { const key = String(s.symbol ?? '') if (!key) return false if (seen.has(key)) return false seen.add(key) return true }) const pctValues = stocks.map(s => num(s.change_pct)).filter((v): v is number => v != null) const turnoverValues = stocks.map(s => num(s.turnover_rate)).filter((v): v is number => v != null) const volValues = stocks.map(s => num(s.vol_ratio_5d)).filter((v): v is number => v != null) const totalAmount = stocks.reduce((sum, s) => sum + (num(s.amount) ?? 0), 0) const upCount = pctValues.filter(v => v > 0).length const downCount = pctValues.filter(v => v < 0).length const flatCount = Math.max(0, stocks.length - upCount - downCount) const strongCount = pctValues.filter(v => v >= 0.05).length const weakCount = pctValues.filter(v => v <= -0.05).length const leader = stocks.length ? [...stocks].sort((a, b) => b.leaderScore - a.leaderScore)[0] : null const avgPct = avg(pctValues) const medianPct = median(pctValues) const upRate = pctValues.length ? upCount / pctValues.length : 0 const amountScore = clamp01(Math.log1p(totalAmount) / Math.log1p(80_000_000_000)) const strongScore = stocks.length ? clamp01(strongCount / Math.max(1, stocks.length * 0.18)) : 0 const leaderPart = clamp01((leader?.leaderScore ?? 0) / 100) const avgPart = clamp01(((avgPct ?? 0) + 0.02) / 0.09) const upPart = clamp01((upRate - 0.35) / 0.55) const heatScore = (avgPart * 0.38 + upPart * 0.2 + strongScore * 0.16 + amountScore * 0.12 + leaderPart * 0.14) * 100 const riskScore = (clamp01((-(avgPct ?? 0) + 0.01) / 0.08) * 0.55 + clamp01(weakCount / Math.max(1, stocks.length * 0.18)) * 0.45) * 100 return { key: group.key, stocks, count: stocks.length, avgPct, medianPct, upCount, downCount, flatCount, upRate, strongCount, weakCount, totalAmount, avgTurnover: avg(turnoverValues), avgVolRatio: avg(volValues), leader, heatScore, riskScore, } } function statSort(mode: SortMode) { return (a: ConceptStat, b: ConceptStat) => { switch (mode) { case 'avgPct': return (b.avgPct ?? -Infinity) - (a.avgPct ?? -Infinity) case 'leader': return (b.leader?.leaderScore ?? -Infinity) - (a.leader?.leaderScore ?? -Infinity) case 'amount': return b.totalAmount - a.totalAmount case 'down': return (a.avgPct ?? Infinity) - (b.avgPct ?? Infinity) case 'heat': default: return b.heatScore - a.heatScore } } } export function ConceptAnalysis() { const [fieldConfig, setFieldConfig] = useState(loadConfig) const [showConfig, setShowConfig] = useState(false) const [search, setSearch] = useState('') const [selectedKey, setSelectedKey] = useState(null) const [sortMode, setSortMode] = useState('heat') const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') const [showRps, setShowRps] = useState(false) const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList }) const availableConfigs = configsQuery.data?.items ?? [] // 用户配置的 configId 可能已失效 (扩展数据被删除), 此时回退到自动选择, // 避免用失效 ID 请求接口报错; 用户仍可点配置按钮重新选择。 const preferredConfigId = fieldConfig.configId || pickBestConfig(availableConfigs) const preferredConfig = availableConfigs.find(c => c.id === preferredConfigId) const activeConfigId = preferredConfig ? preferredConfigId : pickBestConfig(availableConfigs) const activeConfig = availableConfigs.find(c => c.id === activeConfigId) const rowsQuery = useQuery({ queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT), queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }), enabled: !!activeConfigId, }) // 内置概念预设 (ext_gn_ths) 手动获取数据 const PRESET_CONCEPT_ID = 'ext_gn_ths' const queryClient = useQueryClient() const fetchMutation = useMutation({ mutationFn: () => api.extDataPresetFetch(PRESET_CONCEPT_ID), onSuccess: () => { queryClient.invalidateQueries({ queryKey: QK.extData }) queryClient.invalidateQueries({ queryKey: QK.extDataRows(PRESET_CONCEPT_ID, undefined, PAGE_LIMIT) }) }, }) // 是否处于「内置概念预设存在但无数据」状态 → 显示获取按钮 const needsConceptFetch = !!activeConfig && activeConfig.id === PRESET_CONCEPT_ID && !rowsQuery.isLoading && (rowsQuery.data?.total ?? 0) === 0 const marketQuery = useQuery({ queryKey: QK.marketSnapshot, queryFn: api.marketSnapshot, staleTime: 60_000, }) const marketMap = useMemo(() => buildMarketMap(marketQuery.data?.rows ?? []), [marketQuery.data?.rows]) const resolved = useMemo( () => resolveDimension(rowsQuery.data, activeConfig, fieldConfig.dimensionField ? [fieldConfig.dimensionField, ...CANDIDATE_FIELDS] : CANDIDATE_FIELDS), [rowsQuery.data, activeConfig, fieldConfig.dimensionField], ) const stats = useMemo(() => { return resolved.groups .map(g => calcConceptStat(g, marketMap)) .filter(s => s.count > 0) }, [resolved.groups, marketMap]) const filteredStats = useMemo(() => { const q = search.trim().toLowerCase() const base = q ? stats.filter(s => s.key.toLowerCase().includes(q)) : stats return [...base].sort(statSort(sortMode)) }, [stats, search, sortMode]) const selected = filteredStats.find(s => s.key === selectedKey) ?? filteredStats[0] ?? null const leading = useMemo(() => [...stats].sort(statSort('heat')).slice(0, 10), [stats]) const falling = useMemo(() => [...stats].sort(statSort('down')).slice(0, 10), [stats]) const activeConcept = useMemo(() => [...stats].sort(statSort('amount'))[0] ?? null, [stats]) const conceptBreadth = useMemo(() => { const priced = stats.filter(s => s.avgPct != null) return { up: priced.filter(s => (s.avgPct ?? 0) > 0).length, down: priced.filter(s => (s.avgPct ?? 0) < 0).length, flat: priced.filter(s => s.avgPct === 0).length, } }, [stats]) const totalSymbols = useMemo(() => { const set = new Set() stats.forEach(s => s.stocks.forEach(st => { if (st.symbol) set.add(st.symbol) })) return set.size }, [stats]) const handleSaveConfig = (c: AnalysisFieldConfig) => { setFieldConfig(c) saveConfig(c) setSelectedKey(null) } if (configsQuery.isLoading) { return
} if (!activeConfig) { // 极端情况: 无任何概念配置。仍提供一键获取内置概念数据入口 return ( <>
setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源"> } /> fetchMutation.mutate()} />
{showConfig && setShowConfig(false)} />} ) } return ( <> {/* RPS 轮动: 打开涨幅轮动矩阵对话框 */} } />
{ setPreviewSymbol(sym); setPreviewName(name ?? '') }} /> {stats.length > 0 ? (
{ setSearch(v); setSelectedKey(null) }} onSort={setSortMode} onSelect={setSelectedKey} /> { setPreviewSymbol(sym); setPreviewName(name ?? '') }} />
) : rowsQuery.isLoading ? (
正在计算概念强度...
) : needsConceptFetch ? ( fetchMutation.mutate()} /> ) : ( )}
{showConfig && setShowConfig(false)} />} {previewSymbol && ( { setPreviewSymbol(null); setPreviewName('') }} /> )} {showRps && setShowRps(false)} />} ) } function HeroPanel({ leading, falling, activeConcept, conceptBreadth, }: { leading?: ConceptStat falling?: ConceptStat activeConcept?: ConceptStat | null conceptBreadth: { up: number; down: number; flat: number } }) { return (
{fmtPct(leading.avgPct)} : '等待行情'} tone="up" /> {fmtPct(falling.avgPct)} : '等待行情'} tone="down" /> {conceptBreadth.up}/{conceptBreadth.down}} hint={<>上涨/下跌{conceptBreadth.flat ? · 平 {conceptBreadth.flat} : null}} tone="blue" />
) } function HeroMetric({ icon: Icon, label, value, hint, tone }: { icon: typeof TrendingUp label: string value: ReactNode hint: ReactNode tone: 'up' | 'down' | 'gold' | 'blue' }) { const toneClass = { up: 'text-bull bg-bull/10', down: 'text-bear bg-bear/10', gold: 'text-amber-300 bg-amber-400/10', blue: 'text-blue-300 bg-blue-400/10', }[tone] const valueClass = { up: 'text-bull', down: 'text-bear', gold: 'text-amber-300', blue: 'text-foreground', }[tone] return (
{label}
{value}
{hint}
) } function MarketPulse({ leading, falling, selectedKey, onSelect, onStockClick, }: { leading: ConceptStat[] falling: ConceptStat[] selectedKey: string | null onSelect: (key: string) => void onStockClick: (symbol: string, name?: string) => void }) { return (
) } function PulseList({ title, items, mode, selectedKey, onSelect, onStockClick, }: { title: string items: ConceptStat[] mode: 'up' | 'down' selectedKey: string | null onSelect: (key: string) => void onStockClick: (symbol: string, name?: string) => void }) { const toneText = mode === 'up' ? 'text-bull' : 'text-bear' const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20' const toneBg = mode === 'up' ? 'bg-bull/10' : 'bg-bear/10' const toneHover = mode === 'up' ? 'hover:border-bull/35' : 'hover:border-bear/35' return (
{mode === 'up' ? : } {title}
Top 10
{items.map((item, idx) => { const active = selectedKey === item.key const leaders = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, 3) const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0 const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0 const flatPct = Math.max(0, 100 - upPct - downPct) return ( ) })}
) } function ConceptRail({ stats, selectedKey, search, sortMode, onSearch, onSort, onSelect, }: { stats: ConceptStat[] selectedKey: string | null search: string sortMode: SortMode onSearch: (v: string) => void onSort: (v: SortMode) => void onSelect: (v: string) => void }) { return (

概念矩阵

Top {stats.length}
onSearch(e.target.value)} placeholder="搜索概念" className="h-8 w-full rounded-lg border border-border bg-base pl-8 pr-3 text-xs text-foreground outline-none focus:border-accent/50" />
{([ ['heat', '强度'], ['avgPct', '涨幅'], ['leader', '龙头'], ['amount', '成交'], ['down', '跌幅'], ] as [SortMode, string][]).map(([key, label]) => ( ))}
{stats.map(item => { const active = selectedKey === item.key return ( ) })}
) } function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStockClick: (symbol: string, name?: string) => void }) { if (!stat) return null const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS) const topLeaders = stocks.slice(0, 3) return (

{stat.key}

强度 {stat.heatScore.toFixed(0)}
{stat.count} 只成分 平均 {stat.avgPct != null ? fmtPct(stat.avgPct) : '—'} 上涨占比 {(stat.upRate * 100).toFixed(0)}% 成交额 {fmtBigNum(stat.totalAmount)} {stat.avgTurnover != null && 均换手 {stat.avgTurnover.toFixed(2)}%}
{stocks.map((s, idx) => ( onStockClick(s.symbol, s.name || undefined)}> ))}
排名 股票 涨跌幅 换手率 成交额 流通市值 量比 龙头分
{idx + 1}
{s.name || '—'}
{s.symbol}
{s.change_pct != null ? fmtPct(s.change_pct) : '—'} {s.turnover_rate != null ? `${s.turnover_rate.toFixed(2)}%` : '—'} {fmtBigNum(s.amount)} {fmtBigNum(s.float_market_cap ?? s.market_cap)} {s.vol_ratio_5d != null ? s.vol_ratio_5d.toFixed(2) : '—'}
{s.leaderScore.toFixed(0)}
{stat.stocks.length > MAX_RENDERED_STOCKS &&
仅展示龙头分前 {MAX_RENDERED_STOCKS} 只,共 {stat.stocks.length} 只
}
) } function MiniStat({ label, value, cls }: { label: string; value: string; cls: string }) { return
{label}
{value}
} function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void }) { if (!stocks.length) return
暂无龙头候选
return (
本概念三龙头
{stocks.map((stock, idx) => (
onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35')}>
{idx === 0 ? '主龙头' : `辅龙 ${idx}`} {stock.leaderScore.toFixed(0)}
{stock.name || stock.symbol}
{stock.symbol} {stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}
))}
) } function ScoreExplain({ stock }: { stock?: EnrichedStock }) { if (!stock) return
暂无评分拆解
const parts = stock.leaderParts return (
主龙头评分拆解 涨幅 / 换手 / 成交 / 市值 / 量比 / 连板
) } function Part({ label, value, cls }: { label: string; value: number; cls: string }) { return
{label}{Math.round(value * 100)}
}