import { useState, useCallback, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' import { RefreshCw, ChevronDown, Flame, Settings2, X, Bell, BellOff, AlertCircle } from 'lucide-react' import { DatePicker } from '@/components/DatePicker' import { api, type LimitLadderTier, type LimitLadderStock, type MonitorRule } from '@/lib/api' import { StockPreviewDialog } from '@/components/StockPreviewDialog' import { QK } from '@/lib/queryKeys' import { storage } from '@/lib/storage' import { fmtPct, priceColorClass } from '@/lib/format' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { useCapabilities, usePreferences } from '@/lib/useSharedQueries' import { SealedBadge } from '@/components/SealedBadge' import type { ExtColumnDisplayConfig } from '@/lib/list-columns' // ===== Ext 字段配置 ===== /** 每个字段的完整配置:字段来源 + 渲染方式 */ interface ExtFieldItem { /** "config_id.field_name",空=不显示 */ field?: string /** 渲染配置(分隔符、显示模式、maxTags 等) */ display?: ExtColumnDisplayConfig } interface BrokenFailedConfig { /** 炸板:计算N板以上(0=不限,即首板炸板也算) */ brokenMinBoards?: number /** 断板:计算N板以上 */ failedMinBoards?: number /** 是否计算炸板数 */ brokenCount?: boolean /** 是否计算断板数 */ failedCount?: boolean /** 是否显示炸板股票 */ brokenShow?: boolean /** 是否显示断板股票 */ failedShow?: boolean } interface ExtFieldConfig { concept?: ExtFieldItem industry?: ExtFieldItem /** 炸板/断板过滤配置 */ bf?: BrokenFailedConfig /** 显示概念分布统计 */ showConceptStats?: boolean /** 显示行业分布统计 */ showIndustryStats?: boolean /** 显示分组概念分布统计 */ showConceptGroupStats?: boolean /** 显示分组行业分布统计 */ showIndustryGroupStats?: boolean } const DEFAULT_BF: BrokenFailedConfig = { brokenMinBoards: 0, failedMinBoards: 0, brokenCount: true, failedCount: true, brokenShow: true, failedShow: true, } function loadExtFields(): ExtFieldConfig { const raw = storage.limitLadderExtFields.get({}) as any if (!raw) return {} // 兼容旧格式 { concept: "id.field", conceptSep: "x" } if (typeof raw.concept === 'string') { return { concept: raw.concept ? { field: raw.concept, display: { displayMode: 'tag', separator: raw.conceptSep } } : undefined, industry: raw.industry ? { field: raw.industry, display: { displayMode: 'tag', separator: raw.industrySep } } : undefined, } } return raw } /** 根据显示开关过滤 extFields */ function resolveExtFields(fields: ExtFieldConfig, showConcept: boolean, showIndustry: boolean): ExtFieldConfig { return { concept: showConcept ? fields.concept : undefined, industry: showIndustry ? fields.industry : undefined, showConceptGroupStats: fields.showConceptGroupStats, showIndustryGroupStats: fields.showIndustryGroupStats, } } function buildExtColumnsParam(fields: ExtFieldConfig): string | undefined { const parts = [fields.concept?.field, fields.industry?.field].filter(Boolean) return parts.length > 0 ? parts.join(',') : undefined } /** 从 stock row 中取出 ext 字段值,按配置渲染 */ function getExtTags(stock: LimitLadderStock, item?: ExtFieldItem): string[] { if (!item?.field) return [] const key = item.field.replace('.', '__') const v = (stock as unknown as Record)[key] if (v == null) return [] const str = String(v) if (!str) return [] const cfg = item.display if (cfg?.displayMode === 'text') return [str] const sep = cfg?.separator?.trim() || null const tags = sep ? str.split(sep).map(s => s.trim()).filter(Boolean) : str.split(/[、,,;;\-]/).map(s => s.trim()).filter(Boolean) const maxTags = cfg?.maxTags ?? 0 const sliced = maxTags > 0 ? tags.slice(0, maxTags) : tags const hiddenIndices = maxTags > 0 ? cfg?.hiddenIndices : undefined return hiddenIndices?.length ? sliced.filter((_, i) => !hiddenIndices.includes(i)) : sliced } // ===== 方向(涨停/跌停) ===== type Direction = 'up' | 'down' /** 格式化封单量(手/股): 大数转万/亿 */ function fmtSealVol(v: number): string { if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿' if (v >= 1e4) return (v / 1e4).toFixed(1) + '万' return v.toLocaleString() } /** 格式化封单额(元): 大数转万/亿 */ function fmtSealAmount(v: number): string { if (v >= 1e8) return (v / 1e8).toFixed(2) + '亿' if (v >= 1e4) return (v / 1e4).toFixed(0) + '万' return v.toFixed(0) } // ===== 板块标识 ===== function boardTag(symbol: string): { label: string; cls: string } | null { if (/^(300|301)/.test(symbol)) return { label: '创', cls: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' } if (/^688/.test(symbol)) return { label: '科', cls: 'text-cyan-400 bg-cyan-400/12 border-cyan-400/25' } if (/\.BJ$/.test(symbol)) return { label: '北', cls: 'text-purple-400 bg-purple-400/12 border-purple-400/25' } return null } // ===== 状态标识 + 卡片样式 ===== const STATUS_STYLE: Record string); cardStyle?: React.CSSProperties; hoverShadow?: string }> = { limit_up: { bg: '', bar: 'border-l-2 border-bull/50', nameCls: 'text-rose-50 text-[13px]', codeCls: 'text-muted/80', badge: '', badgeText: '', cardStyle: { background: 'linear-gradient(105deg, hsl(4 60% 45% / 0.14) 0%, hsl(6 50% 30% / 0.09) 40%, hsl(220 15% 12% / 0.0) 100%)', boxShadow: 'inset 1px 0 0 hsl(4 80% 55% / 0.12), 0 0 10px -4px hsl(4 80% 50% / 0.10)', }, hoverShadow: 'inset 1px 0 0 hsl(4 80% 55% / 0.30), 0 0 18px -4px hsl(4 80% 50% / 0.28)', }, limit_down: { bg: '', bar: 'border-l-2 border-bear/50', nameCls: 'text-green-50 text-[13px]', codeCls: 'text-muted/80', badge: '', badgeText: '', cardStyle: { background: 'linear-gradient(105deg, hsl(152 60% 45% / 0.14) 0%, hsl(150 50% 30% / 0.09) 40%, hsl(220 15% 12% / 0.0) 100%)', boxShadow: 'inset 1px 0 0 hsl(152 80% 45% / 0.12), 0 0 10px -4px hsl(152 80% 45% / 0.10)', }, hoverShadow: 'inset 1px 0 0 hsl(152 80% 45% / 0.30), 0 0 18px -4px hsl(152 80% 45% / 0.28)', }, broken: { bg: 'opacity-75', bar: 'border-l border-purple-400/30', nameCls: 'text-foreground/70 text-xs', codeCls: 'text-muted/60', badge: 'text-purple-400', badgeText: d => d === 'down' ? '撬' : '炸', }, recovery: { bg: 'opacity-75', bar: 'border-l border-purple-400/30', nameCls: 'text-foreground/70 text-xs', codeCls: 'text-muted/60', badge: 'text-purple-400', badgeText: '撬', }, failed: { bg: 'opacity-75', bar: 'border-l border-muted/25', nameCls: 'text-foreground/70 text-xs', codeCls: 'text-muted/60', badge: 'text-muted/80', badgeText: d => d === 'down' ? '止' : '断', }, } // ===== sealed 降级标识 ===== /** 判定 sealed 是否处于降级状态。 * isHistorical 判定基于"用户选的日期是否早于数据最新日", 而非自然日今天 * (否则休市日/节假日会把最新交易日误判为历史)。 */ function useSealedDegrade(asOf: string, latestDate: string | undefined, sealedReady: boolean | undefined, sealedCounts?: { real: number; fake: number; pending: number }) { const { data: caps } = useCapabilities() const hasDepth = !!caps?.capabilities?.['depth5.batch'] // 历史判定: 用户主动选了早于最新交易日的日期 const isHistorical = !!asOf && !!latestDate && asOf < latestDate // 降级: 无能力 / 历史日期 / 最新日但 sealed 未就绪 const degraded = !hasDepth || isHistorical || !sealedReady return { degraded, hasDepth, isHistorical, sealedReady, sealedCounts } } // ===== 单只股票卡片 ===== function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick }: { stock: LimitLadderStock extFields: ExtFieldConfig direction: Direction sealMode: 'vol' | 'amount' monitored: boolean monitorRule?: MonitorRule onMonitorChange: () => void hasDepth: boolean onClick: () => void }) { const [showMonitorMenu, setShowMonitorMenu] = useState(false) const [menuAnchor, setMenuAnchor] = useState(null) const code = stock.symbol.replace(/\.BJ$/, '').replace(/\.SZ$/, '').replace(/\.SH$/, '') const tag = boardTag(stock.symbol) const status = stock.status || (direction === 'down' ? 'limit_down' : 'limit_up') const style = STATUS_STYLE[status] || STATUS_STYLE[direction === 'down' ? 'limit_down' : 'limit_up'] const isLimitHit = status === 'limit_up' || status === 'limit_down' const conceptTags = getExtTags(stock, extFields.concept) const industryTags = getExtTags(stock, extFields.industry) const isTextConcept = extFields.concept?.display?.displayMode === 'text' const isTextIndustry = extFields.industry?.display?.displayMode === 'text' const conceptLayout = extFields.concept?.display?.tagLayout ?? 'horizontal' const industryLayout = extFields.industry?.display?.tagLayout ?? 'horizontal' // 连板数: 按 direction 选字段 const consecNum = direction === 'down' ? stock.consecutive_limit_downs : stock.consecutive_limit_ups // badgeText 可能是函数(涨跌停共用 status 如 failed/broken) const badgeText = typeof style.badgeText === 'function' ? style.badgeText(direction) : style.badgeText const tagCls = 'text-[9px] leading-none px-1 py-px rounded-sm' const conceptCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-orange-200/60 bg-orange-400/[0.05]' const industryCls = 'text-[10px] leading-none px-1.5 py-0.5 rounded-sm text-sky-300/90 bg-sky-400/10' const textCls = `${tagCls} text-secondary/60 bg-elevated/60` const hasTags = conceptTags.length > 0 || industryTags.length > 0 // 齿轮始终可见: 让免费用户也能看到功能入口, 点开后在菜单内提示权限不足。 // Pro+ 用户正常设置; 免费用户保存按钮禁用 + 显示升级提示。 return (
{/* 监控设置按钮 (右上角): 不能嵌在卡片 button 内 */} {/* 监控菜单 */} {showMonitorMenu && menuAnchor && ( setShowMonitorMenu(false)} onChanged={onMonitorChange} /> )}
) } // ===== 封单监控菜单 ===== function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasDepth, onClose, onChanged }: { stock: LimitLadderStock direction: Direction sealMode: 'vol' | 'amount' monitorRule?: MonitorRule anchorRect: DOMRect hasDepth: boolean onClose: () => void onChanged: () => void }) { const ruleId = `mr_ladder_${stock.symbol.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}` const existing = monitorRule // 推送外部开关默认值: 取偏好设置中的全局默认 (已有规则沿用其值) const { data: prefs } = usePreferences() const webhookDefault: boolean = (prefs as any)?.webhook_enabled_default ?? false // 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元) const VOL_UNITS = [ { key: '1', label: '手', mult: 1 }, { key: '10000', label: '万手', mult: 10000 }, ] const AMT_UNITS = [ { key: '1', label: '元', mult: 1 }, { key: '10000', label: '万元', mult: 10000 }, { key: '100000000', label: '亿元', mult: 100000000 }, ] const [metric, setMetric] = useState<'sealed_vol' | 'sealed_amount'>(existing?.metric ?? (sealMode === 'amount' ? 'sealed_amount' : 'sealed_vol')) const units = metric === 'sealed_amount' ? AMT_UNITS : VOL_UNITS // 已有规则: 反算到最大便捷单位 (选能整除的最大倍率); 新建: 额默认亿元, 量默认万手 const initUnit = (() => { if (!existing || !existing.threshold) return metric === 'sealed_amount' ? '100000000' : '10000' const thr = existing.threshold const matched = [...units].reverse().find(u => thr >= u.mult && thr % u.mult === 0) return matched ? matched.key : units[0].key })() const [unitKey, setUnitKey] = useState(initUnit) const [threshold, setThreshold] = useState(() => { if (!existing || !existing.threshold) return '' const mult = units.find(u => u.key === initUnit)?.mult ?? 1 return String(existing.threshold / mult) }) const [pushExternal, setPushExternal] = useState(existing?.webhook_enabled ?? webhookDefault) const [saving, setSaving] = useState(false) const warnLabel = direction === 'down' ? '翘板预警' : '炸板预警' // 切 metric 时重置单位 (额默认亿元, 量默认万手) + 清空阈值 const switchMetric = (m: 'sealed_vol' | 'sealed_amount') => { setMetric(m) // 额选亿元(key=100000000), 量选万手(key=10000) const defaultKey = m === 'sealed_amount' ? '100000000' : '10000' setUnitKey(defaultKey) setThreshold('') } const handleSave = async () => { const inputValue = Number(threshold) if (!threshold || isNaN(inputValue) || inputValue < 0) return const mult = units.find(u => u.key === unitKey)?.mult ?? 1 const thr = Math.round(inputValue * mult) // 换算回原始单位 (量=手, 额=元) setSaving(true) try { await api.monitorRuleSave({ id: ruleId, name: `封单监控 · ${stock.name ?? stock.symbol}`, enabled: true, type: 'ladder', scope: 'symbols', symbols: [stock.symbol], direction: direction === 'down' ? 'down' : 'up', metric, threshold: thr, conditions: [], logic: 'and', cooldown_seconds: existing?.cooldown_seconds ?? 600, severity: 'warn', message: '', webhook_enabled: pushExternal, } as MonitorRule) onChanged() onClose() } catch { /* toast 已在 api 层处理 */ } finally { setSaving(false) } } const handleRemove = async () => { setSaving(true) try { await api.monitorRuleDelete(ruleId) onChanged() onClose() } catch { /* ignore */ } finally { setSaving(false) } } // 基于齿轮按钮位置算菜单坐标 (fixed 定位, 脱离父级 overflow-hidden 裁剪) const MENU_W = 240 // w-60 = 15rem = 240px const MENU_H = 340 // 预估高度 (含标题栏 + 4 行设置 + 权限提示 + 按钮区) const anchorRight = anchorRect.right const anchorBottom = anchorRect.bottom // 水平: 默认右对齐齿轮; 超出右边则左移 const left = Math.max(8, Math.min(anchorRight - MENU_W, window.innerWidth - MENU_W - 8)) // 垂直: 默认在齿轮下方; 超出底部则上方 const top = anchorBottom + MENU_H > window.innerHeight ? Math.max(8, anchorRect.top - MENU_H) : anchorBottom + 4 return ( <>
{/* 标题栏: 股票名 + 预警类型 */}
{stock.name ?? stock.symbol}
{/* 预警类型徽章 */}
类型 {warnLabel}
{/* 监控指标: 段控风格 */}
指标
{/* 阈值: 输入 + 单位 */}
阈值 setThreshold(e.target.value)} placeholder="≤ 报警" className="flex-1 min-w-0 h-7 px-2 rounded bg-base border border-border text-foreground text-center tabular-nums placeholder:text-muted/40 focus:outline-none focus:border-accent/50" />
{/* 推送渠道: 胶囊标签 (后续可扩展钉钉/企微等), 选中带强调色 */}
推送
{/* 权限提示 (免费用户) */} {!hasDepth && (
当前 Key 权限无法获取五档行情,后续会适配免费数据源
)}
{/* 底部按钮区 */}
{existing && ( )}
) } // ===== 过滤(多选) ===== type FilterKey = 'limit_up' | 'broken' | 'failed' | 'limit_down' | 'recovery' | 'main' | 'chinext' | 'star' | 'bj' | 'st' const STATUS_TABS_UP: { key: FilterKey; label: string }[] = [ { key: 'limit_up', label: '涨停' }, { key: 'broken', label: '炸板' }, { key: 'failed', label: '断板' }, ] const STATUS_TABS_DOWN: { key: FilterKey; label: string }[] = [ { key: 'limit_down', label: '跌停' }, { key: 'recovery', label: '翘板' }, { key: 'failed', label: '止跌' }, ] function statusTabs(direction: Direction) { return direction === 'down' ? STATUS_TABS_DOWN : STATUS_TABS_UP } const BOARD_TABS: { key: FilterKey; label: string }[] = [ { key: 'main', label: 'A主板' }, { key: 'chinext', label: '创业板' }, { key: 'star', label: '科创板' }, { key: 'bj', label: '北交所' }, { key: 'st', label: 'ST' }, ] function matchFilter(stock: LimitLadderStock, key: FilterKey): boolean { const s = stock.symbol const n = (stock.name ?? '').toUpperCase() switch (key) { case 'limit_up': return stock.status === 'limit_up' || !stock.status case 'limit_down': return stock.status === 'limit_down' case 'broken': return stock.status === 'broken' case 'recovery': return stock.status === 'recovery' case 'failed': return stock.status === 'failed' case 'main': return !/^(300|301|688)/.test(s) && !/\.BJ$/.test(s) && !n.includes('ST') case 'chinext': return /^(300|301)/.test(s) case 'star': return /^688/.test(s) case 'bj': return /\.BJ$/.test(s) case 'st': return n.includes('ST') } } function isStatusKey(key: FilterKey): boolean { return key === 'limit_up' || key === 'limit_down' || key === 'broken' || key === 'recovery' || key === 'failed' } function filterTiers(tiers: LimitLadderTier[], keys: Set, bf?: BrokenFailedConfig): LimitLadderTier[] { const cfg = { ...DEFAULT_BF, ...bf } if (keys.size === 0) return tiers const statusKeys = [...keys].filter(isStatusKey) const boardKeys = [...keys].filter(k => !isStatusKey(k)) return tiers .map(t => ({ ...t, stocks: t.stocks.filter(s => { // 炸板/翘板:先按 boards 阈值过滤 (broken 涨停侧, recovery 跌停侧共用 broken 配置) const isBrokenLike = s.status === 'broken' || s.status === 'recovery' if (isBrokenLike && (cfg.brokenMinBoards ?? 0) > 0 && t.boards < (cfg.brokenMinBoards ?? 0)) return false // 断板/止跌:按 boards 阈值过滤 (failed 涨跌停两侧共用) if (s.status === 'failed' && (cfg.failedMinBoards ?? 0) > 0 && t.boards < (cfg.failedMinBoards ?? 0)) return false // 炸板/翘板:是否显示 if (isBrokenLike && !cfg.brokenShow) return false // 断板/止跌:是否显示 if (s.status === 'failed' && !cfg.failedShow) return false // 状态组 AND 板块组:两组各至少匹配一个 const statusOk = statusKeys.length === 0 || statusKeys.some(k => matchFilter(s, k)) if (!statusOk) return false const boardOk = boardKeys.length === 0 || boardKeys.some(k => matchFilter(s, k)) if (!boardOk) return false return true }), })) .map(t => ({ ...t, count: t.stocks.length })) .filter(t => t.count > 0) } // ===== 过滤持久化 ===== const DEFAULT_FILTERS = new Set(['limit_up', 'main', 'chinext', 'star', 'bj']) function loadFilterKeys(): Set { const arr = storage.limitLadderBoard.get([]) const allTabs = [...STATUS_TABS_UP, ...BOARD_TABS] const valid = arr.filter((k): k is FilterKey => allTabs.some(t => t.key === k)) return valid.length > 0 ? new Set(valid) : new Set(DEFAULT_FILTERS) } // ===== 梯队颜色 ===== const TIER_COLORS: Record = { 1: 'border-border', 2: 'border-yellow-600/40', 3: 'border-orange-500/50', } const TIER_TEXT: Record = { 1: 'text-muted', 2: 'text-yellow-500', 3: 'text-orange-400', } function tierBorder(n: number): string { for (let i = Math.min(n, 20); i >= 1; i--) { if (TIER_COLORS[i]) return TIER_COLORS[i] } return 'border-border' } function tierTextCls(n: number): string { for (let i = Math.min(n, 20); i >= 1; i--) { if (TIER_TEXT[i]) return TIER_TEXT[i] } return 'text-muted' } function tierLabel(n: number, direction: Direction): string { if (direction === 'down') return n === 1 ? '首跌' : `${n}连跌` return n === 1 ? '首板' : `${n}板` } // ===== 梯队总览条 ===== function OverviewBar({ tiers, dateValue, onDateChange, filterKeys, bf, direction }: { tiers: LimitLadderTier[] dateValue: string onDateChange: (v: string) => void filterKeys: Set bf?: BrokenFailedConfig direction: Direction }) { if (tiers.length === 0) return null const cfg = { ...DEFAULT_BF, ...bf } const mainStatus = direction === 'down' ? 'limit_down' : 'limit_up' const brokenStatus = direction === 'down' ? 'recovery' : 'broken' // 命中数: 涨停/跌停主状态(含无 status 兜底) const limitUpCounts = tiers.map(t => t.stocks.filter(s => s.status === mainStatus || !s.status).length) const maxCount = Math.max(...limitUpCounts, 1) const showBroken = (filterKeys.has('broken') || filterKeys.has('recovery')) && cfg.brokenShow const showFailed = filterKeys.has('failed') && cfg.failedShow const totalBroken = cfg.brokenCount ? tiers.reduce((s, t) => s + t.stocks.filter(st => st.status === brokenStatus).length, 0) : 0 const totalFailed = cfg.failedCount ? tiers.reduce((s, t) => s + t.stocks.filter(st => st.status === 'failed').length, 0) : 0 const brokenLabel = direction === 'down' ? '翘板' : '炸板' const failedLabel = direction === 'down' ? '止跌' : '断板' return (
{tiers.map((t, idx) => { const luCount = limitUpCounts[idx] return (
{tierLabel(t.boards, direction)}
{luCount}
) })} {showBroken && totalBroken > 0 && ( {brokenLabel} {totalBroken} )} {showFailed && totalFailed > 0 && ( {failedLabel} {totalFailed} )}
) } // ===== 标签统计面板 ===== function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSelect, direction }: { title: string tiers: LimitLadderTier[] extFields: ExtFieldConfig fieldKey: 'concept' | 'industry' color: { text: [number, number, number]; bg: [number, number, number] } selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null onSelect: (sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => void direction: Direction }) { const [expanded, setExpanded] = useState(false) const mainStatus = direction === 'down' ? 'limit_down' : 'limit_up' const stats = useMemo(() => { const item = extFields[fieldKey] if (!item?.field) return [] as [string, number][] const counts = new Map() for (const t of tiers) { for (const s of t.stocks) { if (s.status && s.status !== mainStatus) continue const tags = getExtTags(s, item) for (const tag of tags) { counts.set(tag, (counts.get(tag) || 0) + 1) } } } return [...counts.entries()].sort((a, b) => b[1] - a[1]) }, [tiers, extFields, fieldKey, mainStatus]) if (stats.length === 0) return null const maxCount = stats[0]?.[1] ?? 1 const [r, g, b] = color.text const [br, bg, bb] = color.bg const needsExpand = stats.length > 10 return (
{stats.map(([name, count]) => { const intensity = Math.max(0.15, count / maxCount) const isSelected = selectedTag?.fieldKey === fieldKey && selectedTag?.tag === name return ( ) })}
{/* 折叠渐变遮罩 */} {needsExpand && !expanded && (
)}
) } // ===== 梯队分组 ===== function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth }: { tier: LimitLadderTier defaultOpen: boolean extFields: ExtFieldConfig filterKeys: Set bf?: BrokenFailedConfig onStockClick: (symbol: string, name?: string) => void selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null onSelectTag: (sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => void direction: Direction sealMode: 'vol' | 'amount' monitoredSymbols: Set ladderRules: Map onMonitorChange: () => void hasDepth: boolean }) { const [open, setOpen] = useState(defaultOpen) const cfg = { ...DEFAULT_BF, ...bf } const mainStatus = direction === 'down' ? 'limit_down' : 'limit_up' const brokenStatus = direction === 'down' ? 'recovery' : 'broken' const brokenBadge = direction === 'down' ? '撬' : '炸' const failedBadge = direction === 'down' ? '止' : '断' const showBroken = (filterKeys.has('broken') || filterKeys.has('recovery')) && cfg.brokenShow const showFailed = filterKeys.has('failed') && cfg.failedShow const luCount = tier.stocks.filter(s => s.status === mainStatus || !s.status).length const brCount = cfg.brokenCount ? tier.stocks.filter(s => s.status === brokenStatus).length : 0 const faCount = cfg.failedCount ? tier.stocks.filter(s => s.status === 'failed').length : 0 // 分组概念/行业统计 const groupConceptStats = useMemo(() => { if (!extFields.showConceptGroupStats || !extFields.concept?.field) return [] const counts = new Map() for (const s of tier.stocks) { if (s.status && s.status !== mainStatus) continue for (const tag of getExtTags(s, extFields.concept)) { counts.set(tag, (counts.get(tag) || 0) + 1) } } return [...counts.entries()].sort((a, b) => b[1] - a[1]) }, [tier.stocks, extFields, mainStatus]) const groupIndustryStats = useMemo(() => { if (!extFields.showIndustryGroupStats || !extFields.industry?.field) return [] const counts = new Map() for (const s of tier.stocks) { if (s.status && s.status !== mainStatus) continue for (const tag of getExtTags(s, extFields.industry)) { counts.set(tag, (counts.get(tag) || 0) + 1) } } return [...counts.entries()].sort((a, b) => b[1] - a[1]) }, [tier.stocks, extFields, mainStatus]) const hasGroupStats = groupConceptStats.length > 0 || groupIndustryStats.length > 0 return ( {/* 头部 */} {/* 股票列表 */} {open && ( {/* 分组统计 */} {hasGroupStats && (
{groupConceptStats.length > 0 && (
概念 {groupConceptStats.slice(0, 20).map(([name, count]) => { const isSelected = selectedTag?.fieldKey === 'concept' && selectedTag?.tag === name return ( ) })}
)} {groupIndustryStats.length > 0 && (
行业 {groupIndustryStats.slice(0, 20).map(([name, count]) => { const isSelected = selectedTag?.fieldKey === 'industry' && selectedTag?.tag === name return ( ) })}
)}
)}
{[...tier.stocks] .filter(s => { if (!selectedTag) return true const item = extFields[selectedTag.fieldKey] if (!item) return true const tags = getExtTags(s, item) return tags.includes(selectedTag.tag) }) .sort((a, b) => { // 开启监控的卡片排到分组最前 const ma = monitoredSymbols.has(a.symbol) ? 0 : 1 const mb = monitoredSymbols.has(b.symbol) ? 0 : 1 if (ma !== mb) return ma - mb const ord = (s: string) => { if (s === 'limit_up' || s === 'limit_down' || !s) return 0 if (s === 'broken' || s === 'recovery') return 1 return 2 } const oa = ord(a.status ?? '') const ob = ord(b.status ?? '') if (oa !== ob) return oa - ob // 同状态(主状态=涨停/跌停)内: 按封单从高到低排, 无封单排末尾。 // 封单额 = sealed_vol(手) × 100 × close, 与展示口径一致。 if (oa === 0) { const sealVal = (s: typeof a) => { if (s.sealed_vol == null) return -1 return sealMode === 'amount' && s.close ? s.sealed_vol * 100 * s.close : s.sealed_vol } return sealVal(b) - sealVal(a) } return 0 }).map(s => ( onStockClick(s.symbol, s.name ?? undefined)} /> ))}
)}
) } // ===== 字段配置弹窗 ===== type SchemaOption = { id: string; label: string; columns: { name: string; label: string }[] } /** 开关行 */ function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) { return ( ) } /** 数字输入行 */ function NumInput({ label, value, onChange, min, max, placeholder }: { label: string; value: number | undefined; onChange: (v: number | undefined) => void; min?: number; max?: number; placeholder?: string }) { return ( ) } /** 字段选择下拉 */ function FieldSelect({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: SchemaOption[] }) { return (
) } /** 扩展字段配置区(概念/行业) */ function ExtFieldSection({ item, onChange, options }: { item: ExtFieldItem | undefined onChange: (item: ExtFieldItem | undefined) => void options: SchemaOption[] }) { const field = item?.field ?? '' const cfg = item?.display const displayMode = cfg?.displayMode ?? 'tag' const updateDisplay = (patch: Partial) => { onChange({ field, display: { displayMode: 'tag', ...cfg, ...patch } }) } return (
选择字段 onChange(v ? { field: v, display: { displayMode: 'tag' } } : undefined)} options={options} />
{!field ? null : ( <>
显示模式
{displayMode === 'tag' && (
分隔符 updateDisplay({ separator: e.target.value })} placeholder="留空" className="flex-1 min-w-0 h-7 bg-elevated border border-border rounded text-xs text-foreground px-2 placeholder:text-muted focus:outline-none focus:border-accent/50" />
留空自动识别:、 , , ; ; -
)} {displayMode === 'tag' && (
显示前N个 { const v = e.target.value ? Number(e.target.value) : undefined updateDisplay({ maxTags: v, ...(v ? {} : { hiddenIndices: undefined }) }) }} placeholder="不限制" className="flex-1 min-w-0 h-7 bg-elevated border border-border rounded text-xs text-foreground px-2 placeholder:text-muted focus:outline-none focus:border-accent/50" />
)} {displayMode === 'tag' && (cfg?.maxTags ?? 0) > 0 && (
显示位置
{Array.from({ length: cfg!.maxTags! }, (_, i) => { const hidden = cfg?.hiddenIndices?.includes(i) return ( ) })}
)} {displayMode === 'tag' && (
排列方向
)}
)}
) } /** 炸板/断板配置区 */ function BrokenFailedSection({ bf, onChange }: { bf: BrokenFailedConfig onChange: (bf: BrokenFailedConfig) => void }) { const update = (patch: Partial) => onChange({ ...bf, ...patch }) return (
{/* 炸板 */}
炸板 update({ brokenShow: v })} /> update({ brokenCount: v })} /> update({ brokenMinBoards: v ?? 0 })} min={0} max={50} placeholder="0=不限" /> {(bf.brokenMinBoards ?? 0) > 0 && ( 低于 {bf.brokenMinBoards} 板的炸板不显示也不计数 )}
{/* 断板 */}
断板 update({ failedShow: v })} /> update({ failedCount: v })} /> update({ failedMinBoards: v ?? 0 })} min={0} max={50} placeholder="0=不限" /> {(bf.failedMinBoards ?? 0) > 0 && ( 低于 {bf.failedMinBoards} 板的断板不显示也不计数 )}
) } function ExtConfigDialog({ fields, onSave, onClose }: { fields: ExtFieldConfig onSave: (f: ExtFieldConfig) => void onClose: () => void }) { const [draft, setDraft] = useState(fields) const { data: schemaData } = useQuery({ queryKey: QK.extDataSchemaAll, queryFn: api.extDataSchemaAll, }) const options = useMemo((): SchemaOption[] => { if (!schemaData?.items) return [] return schemaData.items.filter(item => item.columns.some(c => c.name !== 'symbol' && c.name !== 'code' && c.name !== 'date') ).map(item => ({ id: item.id, label: item.label, columns: item.columns.filter(c => c.name !== 'symbol' && c.name !== 'code' && c.name !== 'date' && (!c.type || c.type === 'VARCHAR' || c.type === 'STRING' || c.type === 'TEXT' || c.type.toLowerCase().includes('char') || c.type.toLowerCase().includes('string')) ), })).filter(item => item.columns.length > 0) }, [schemaData]) return (
e.stopPropagation()} > {/* 头部 */}
配置
{/* 三列平铺 */}
概念 setDraft(d => ({ ...d, concept: v }))} options={options} />
setDraft(d => ({ ...d, showConceptStats: v }))} />
setDraft(d => ({ ...d, showConceptGroupStats: v }))} />
行业 setDraft(d => ({ ...d, industry: v }))} options={options} />
setDraft(d => ({ ...d, showIndustryStats: v }))} />
setDraft(d => ({ ...d, showIndustryGroupStats: v }))} />
炸板/断板 setDraft(d => ({ ...d, bf: v }))} />
{/* 底部按钮 */}
) } // ===== 主页面 ===== export function LimitUpLadder() { const [asOf, setAsOf] = useState('') const [direction, setDirection] = useState(() => storage.limitLadderDirection.get('up')) const [sealMode, setSealMode] = useState<'vol' | 'amount'>(() => storage.limitLadderSealMode.get('vol')) const [filterKeys, setFilterKeys] = useState>(loadFilterKeys) const [extFields, setExtFields] = useState(loadExtFields) const [showExtConfig, setShowExtConfig] = useState(false) const [showConcept, setShowConcept] = useState(() => storage.limitLadderShowExt.get({ concept: true, industry: true }).concept) const [showIndustry, setShowIndustry] = useState(() => storage.limitLadderShowExt.get({ concept: true, industry: true }).industry) // 连板梯队封单监控规则 (type=ladder): {symbol → rule} 映射 const { data: monitorRulesData, refetch: refetchMonitorRules } = useQuery({ queryKey: ['monitor-rules'], queryFn: () => api.monitorRulesList(), staleTime: 30 * 1000, }) const ladderRules = useMemo(() => { const all = monitorRulesData?.rules ?? [] const m = new Map() for (const r of all) { if (r.type === 'ladder' && r.enabled && r.symbols[0]) { m.set(r.symbols[0], r) } } return m }, [monitorRulesData]) const monitoredSymbols = useMemo(() => new Set(ladderRules.keys()), [ladderRules]) const toggleDirection = useCallback((d: Direction) => { setDirection(d) storage.limitLadderDirection.set(d) // 切换方向时重置状态筛选为该方向默认集(避免涨跌状态键错配) const defaultKeys = d === 'down' ? ['limit_down', 'main', 'chinext', 'star', 'bj'] : ['limit_up', 'main', 'chinext', 'star', 'bj'] const allTabs = [...statusTabs(d), ...BOARD_TABS] const valid = defaultKeys.filter(k => allTabs.some(t => t.key === k)) as FilterKey[] setFilterKeys(new Set(valid)) storage.limitLadderBoard.set(valid) }, []) const toggleConcept = useCallback(() => { setShowConcept(prev => { const next = !prev storage.limitLadderShowExt.set({ concept: next, industry: showIndustry }) return next }) }, [showIndustry]) const toggleIndustry = useCallback(() => { setShowIndustry(prev => { const next = !prev storage.limitLadderShowExt.set({ concept: showConcept, industry: next }) return next }) }, [showConcept]) const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') const [selectedTag, setSelectedTag] = useState<{ fieldKey: 'concept' | 'industry'; tag: string } | null>(null) const handleSelectTag = useCallback((sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => { setSelectedTag(prev => prev?.fieldKey === sel?.fieldKey && prev?.tag === sel?.tag ? null : sel) }, []) const toggleFilter = useCallback((key: FilterKey) => { setFilterKeys(prev => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) storage.limitLadderBoard.set([...next]) return next }) }, []) const handleSaveExtFields = useCallback((f: ExtFieldConfig) => { setExtFields(f) storage.limitLadderExtFields.set(f) }, []) const handleStockClick = (symbol: string, name?: string) => { setPreviewSymbol(symbol) setPreviewName(name ?? '') } const extColumnsParam = useMemo(() => buildExtColumnsParam(extFields), [extFields]) const { data, isLoading, refetch, isFetching } = useQuery({ queryKey: [QK.limitLadder(asOf || undefined), extColumnsParam, direction], queryFn: () => api.limitLadder(asOf || undefined, extColumnsParam, direction), staleTime: 5 * 60_000, }) const rawTiers = data?.tiers ?? [] const tiers = filterTiers(rawTiers, filterKeys, extFields.bf) const displayDate = data?.as_of ?? asOf // sealed 降级判定 const sealedDegrade = useSealedDegrade(asOf, data?.as_of, data?.sealed_ready, data?.sealed_counts) if (isLoading) { return (
) } const dateValue = displayDate || new Date().toISOString().slice(0, 10) if (!data || rawTiers.length === 0) { return (
) } return (
{/* 涨跌停切换(胶囊式): 点击切换方向, 当前方向有背景 */}
} right={
{/* 封单模式: 成交量/金额(仅 sealed 就绪时显示) — 胶囊式 */} {data?.sealed_ready && ( <>
{(['vol', 'amount'] as const).map(m => ( ))}
)} {/* 状态组: 涨停/炸板/断板 或 跌停/翘板/止跌 */} {statusTabs(direction).map(tab => ( ))}
{/* 显示组: 概念/行业 */}
{/* 板块组 */} {BOARD_TABS.map(tab => ( ))}
} /> {/* 总览条 + 日期 */} {/* 概念统计 */} {(extFields.showConceptStats ?? true) && ( )} {/* 行业统计 */} {(extFields.showIndustryStats ?? true) && ( )} {/* 梯队列表 */}
{tiers.map(t => ( = 1 || t.count <= 8} extFields={resolveExtFields(extFields, showConcept, showIndustry)} filterKeys={filterKeys} bf={extFields.bf} onStockClick={handleStockClick} selectedTag={selectedTag} onSelectTag={handleSelectTag} direction={direction} sealMode={sealMode} monitoredSymbols={monitoredSymbols} ladderRules={ladderRules} onMonitorChange={refetchMonitorRules} hasDepth={sealedDegrade.hasDepth} /> ))}
{/* 个股K线弹窗 */} setPreviewSymbol(null)} /> {/* 字段配置弹窗 */} {showExtConfig && ( setShowExtConfig(false)} /> )}
) }