Files
stock/serve/frontend/src/pages/LimitUpLadder.tsx
T
2026-07-04 17:41:08 +08:00

1693 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>)[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, { bg: string; bar: string; nameCls: string; codeCls: string; badge: string; badgeText: string | ((d: Direction) => 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<DOMRect | null>(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 (
<div className="relative group w-full">
{/* 监控设置按钮 (右上角): 不能嵌在卡片 button 内 */}
<button
onClick={e => {
e.stopPropagation()
setMenuAnchor(e.currentTarget.getBoundingClientRect())
setShowMonitorMenu(v => !v)
}}
title={monitored ? '封单监控已开启' : '开启封单监控'}
className={`absolute top-1 right-1 z-20 p-0.5 rounded transition-opacity cursor-pointer ${
monitored ? 'opacity-100 text-amber-400' : 'opacity-0 group-hover:opacity-70 text-muted hover:!opacity-100'
}`}
>
{monitored ? <Bell className="h-3 w-3" /> : <BellOff className="h-3 w-3" />}
</button>
{/* 监控菜单 */}
{showMonitorMenu && menuAnchor && (
<MonitorMenu
stock={stock}
direction={direction}
sealMode={sealMode}
monitorRule={monitorRule}
anchorRect={menuAnchor}
hasDepth={hasDepth}
onClose={() => setShowMonitorMenu(false)}
onChanged={onMonitorChange}
/>
)}
<button
onClick={onClick}
className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''}`}
style={style.cardStyle ? { ...style.cardStyle } : undefined}
onMouseEnter={e => {
if (!style.cardStyle || !style.hoverShadow) return
e.currentTarget.style.boxShadow = style.hoverShadow
}}
onMouseLeave={e => {
if (!style.cardStyle) return
e.currentTarget.style.boxShadow = style.cardStyle.boxShadow ?? ''
}}
>
{/* 名称行 */}
<div className="flex items-center gap-1.5 w-full min-w-0 pr-4">
<span className={`${style.nameCls} font-medium truncate`}>{stock.name}</span>
{tag && (
<span className={`shrink-0 text-[9px] px-1 py-px rounded-full border leading-none ${tag.cls}`}>{tag.label}</span>
)}
</div>
{/* 代码 + 数字行 */}
<div className="flex items-center gap-1.5 w-full">
<span className={`${style.codeCls} font-mono text-[10px] tracking-tight`}>{code}</span>
<span className="ml-auto flex items-center gap-1">
{!isLimitHit ? (
<span className={`text-[10px] font-semibold tabular-nums ${priceColorClass(stock.change_pct)}`}>
{fmtPct(stock.change_pct)}
</span>
) : stock.sealed_status === 'real' && stock.sealed_vol != null ? (
/* 已修正真封板: 右侧显示封单(量或额, 替代连板数)。
sealed_vol 单位是手, 1手=100股, 算金额需 ×100 */
<span className="text-[10px] font-semibold tabular-nums text-accent/80">
{sealMode === 'amount' && stock.close
? fmtSealAmount(stock.sealed_vol * 100 * stock.close)
: fmtSealVol(stock.sealed_vol)}
</span>
) : stock.sealed_status === 'pending' ? (
<span className="text-[9px] text-yellow-500/60 leading-none">待确认</span>
) : (
/* 未修正: 显示连板数 */
<span className="text-[10px] font-semibold tabular-nums text-accent/80">
{consecNum}
</span>
)}
{badgeText && (
<span className={`text-[9px] font-medium ${style.badge}`}>{badgeText}</span>
)}
</span>
</div>
{/* 标签行 */}
{hasTags && (
<div className="flex flex-col gap-0.5 w-full">
{conceptTags.length > 0 && (
<div className={`flex gap-0.5 ${conceptLayout === 'vertical' ? 'flex-col items-start' : 'flex-wrap'}`}>
{conceptTags.map((t, i) => (
<span key={i} className={isTextConcept ? textCls : conceptCls}>{t}</span>
))}
</div>
)}
{industryTags.length > 0 && (
<div className={`flex gap-0.5 ${industryLayout === 'vertical' ? 'flex-col items-start' : 'flex-wrap'}`}>
{industryTags.map((t, i) => (
<span key={i} className={isTextIndustry ? textCls : industryCls}>{t}</span>
))}
</div>
)}
</div>
)}
</button>
</div>
)
}
// ===== 封单监控菜单 =====
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<string>(() => {
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 (
<>
<div className="fixed inset-0 z-40" onClick={onClose} />
<div
className="fixed z-50 w-60 rounded-lg bg-surface border border-border shadow-xl text-xs overflow-hidden"
style={{ left, top }}
>
{/* 标题栏: 股票名 + 预警类型 */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-elevated/40">
<div className="flex items-center gap-1.5 min-w-0">
<Bell className="h-3.5 w-3.5 text-amber-400 shrink-0" />
<span className="font-medium text-foreground truncate">{stock.name ?? stock.symbol}</span>
</div>
<button onClick={onClose} className="text-muted hover:text-foreground shrink-0"><X className="h-3.5 w-3.5" /></button>
</div>
<div className="px-3 py-2.5 space-y-2.5">
{/* 预警类型徽章 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0">类型</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${direction === 'down' ? 'bg-bear/15 text-bear' : 'bg-bull/15 text-bull'}`}>
{warnLabel}
</span>
</div>
{/* 监控指标: 段控风格 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0 w-8">指标</span>
<div className="flex gap-0.5 flex-1 bg-elevated/50 rounded p-0.5">
<button
onClick={() => switchMetric('sealed_vol')}
className={`flex-1 px-2 py-1 rounded text-[11px] transition-colors ${metric === 'sealed_vol' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'}`}
>封单量</button>
<button
onClick={() => switchMetric('sealed_amount')}
className={`flex-1 px-2 py-1 rounded text-[11px] transition-colors ${metric === 'sealed_amount' ? 'bg-surface text-foreground shadow-sm' : 'text-muted hover:text-secondary'}`}
>封单额</button>
</div>
</div>
{/* 阈值: 输入 + 单位 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0 w-8">阈值</span>
<input
type="number"
value={threshold}
onChange={e => 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"
/>
<select
value={unitKey}
onChange={e => setUnitKey(e.target.value)}
className="h-7 px-1.5 rounded bg-base border border-border text-secondary text-[11px] focus:outline-none focus:border-accent/50 cursor-pointer"
>
{units.map(u => (
<option key={u.key} value={u.key}>{u.label}</option>
))}
</select>
</div>
{/* 推送渠道: 胶囊标签 (后续可扩展钉钉/企微等), 选中带强调色 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted shrink-0 w-8">推送</span>
<button
type="button"
onClick={() => setPushExternal(v => !v)}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-medium transition-colors border cursor-pointer ${
pushExternal
? 'bg-accent/15 text-accent border-accent/40'
: 'bg-elevated/40 text-muted border-border hover:text-secondary'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${pushExternal ? 'bg-accent' : 'bg-muted/50'}`} />
飞书
</button>
</div>
{/* 权限提示 (免费用户) */}
{!hasDepth && (
<div className="flex items-start gap-1.5 rounded border border-amber-400/30 bg-amber-400/5 px-2 py-1.5 text-[10px] leading-relaxed text-amber-400/90">
<AlertCircle className="h-3 w-3 shrink-0 mt-px" />
<span>当前 Key 权限无法获取五档行情,后续会适配免费数据源</span>
</div>
)}
</div>
{/* 底部按钮区 */}
<div className="flex items-center gap-2 px-3 py-2.5 border-t border-border bg-elevated/30">
{existing && (
<button
onClick={handleRemove}
disabled={saving || !hasDepth}
className="shrink-0 h-7 px-2.5 rounded text-[11px] text-muted hover:text-danger hover:bg-danger/5 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
>关闭监控</button>
)}
<button
onClick={handleSave}
disabled={saving || !threshold || !hasDepth}
title={!hasDepth ? '需 Pro+ 套餐 (批量五档能力)' : ''}
className="flex-1 h-7 rounded text-[11px] font-medium transition-all cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed bg-accent text-white hover:bg-accent/90 active:scale-[0.98] disabled:active:scale-100"
>
{saving ? '保存中…' : !hasDepth ? '需 Pro+ 套餐' : existing ? '更新监控' : '开启监控'}
</button>
</div>
</div>
</>
)
}
// ===== 过滤(多选) =====
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<FilterKey>, 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<FilterKey>(['limit_up', 'main', 'chinext', 'star', 'bj'])
function loadFilterKeys(): Set<FilterKey> {
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<number, string> = {
1: 'border-border',
2: 'border-yellow-600/40',
3: 'border-orange-500/50',
}
const TIER_TEXT: Record<number, string> = {
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<FilterKey>
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 (
<div className="flex items-center gap-4 px-5 py-2">
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-secondary">
{tiers.map((t, idx) => {
const luCount = limitUpCounts[idx]
return (
<div key={t.boards} className="flex items-center gap-1">
<span className={`font-medium ${tierTextCls(t.boards)}`}>{tierLabel(t.boards, direction)}</span>
<div
className="h-2 rounded-sm bg-accent/40"
style={{ width: `${Math.max(8, (luCount / maxCount) * 48)}px` }}
/>
<span className="text-muted">{luCount}</span>
</div>
)
})}
{showBroken && totalBroken > 0 && (
<span className="text-purple-400 font-medium">{brokenLabel} {totalBroken}</span>
)}
{showFailed && totalFailed > 0 && (
<span className="text-yellow-500 font-medium">{failedLabel} {totalFailed}</span>
)}
</div>
<div className="ml-auto">
<DatePicker value={dateValue} onChange={onDateChange} />
</div>
</div>
)
}
// ===== 标签统计面板 =====
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<string, number>()
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 (
<div className="px-4">
<button
onClick={() => needsExpand && setExpanded(v => !v)}
className={`flex items-center gap-1.5 mb-1.5 w-full group ${needsExpand ? 'cursor-pointer' : 'cursor-default'}`}
>
<span className="text-[10px] tracking-wider text-muted">{title}</span>
<span className="text-[10px] text-muted/50">{stats.length}</span>
{needsExpand && (
<span className="text-[10px] text-muted/60 group-hover:text-muted ml-auto flex items-center gap-0.5 transition-colors">
{expanded ? '收起' : '展开'}
<ChevronDown className={`h-3 w-3 transition-transform ${expanded ? 'rotate-180' : ''}`} />
</span>
)}
</button>
<div className="relative">
<div
className={`flex flex-wrap gap-1.5 pt-0.5 pl-1 transition-all duration-300 ${
expanded ? 'pb-2.5' : 'pb-0.5 max-h-[3.5rem] overflow-hidden'
}`}
>
{stats.map(([name, count]) => {
const intensity = Math.max(0.15, count / maxCount)
const isSelected = selectedTag?.fieldKey === fieldKey && selectedTag?.tag === name
return (
<button
key={name}
onClick={() => onSelect(isSelected ? null : { fieldKey, tag: name })}
className="text-[11px] px-2 py-1 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
color: isSelected ? '#fff' : `rgba(${r},${g},${b},${0.6 + intensity * 0.4})`,
backgroundColor: isSelected
? `rgba(${br},${bg},${bb},0.7)`
: `rgba(${br},${bg},${bb},${intensity * 0.2})`,
outline: isSelected ? `1px solid rgba(${r},${g},${b},0.8)` : 'none',
outlineOffset: 1,
}}
>
{name}
<span className="ml-1" style={{ opacity: isSelected ? 0.8 : 0.6 }}>{count}</span>
</button>
)
})}
</div>
{/* 折叠渐变遮罩 */}
{needsExpand && !expanded && (
<div
className="absolute bottom-0 left-0 right-0 h-4 pointer-events-none"
style={{ background: 'linear-gradient(to bottom, transparent 0%, hsl(var(--surface)) 100%)' }}
/>
)}
</div>
</div>
)
}
// ===== 梯队分组 =====
function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth }: {
tier: LimitLadderTier
defaultOpen: boolean
extFields: ExtFieldConfig
filterKeys: Set<FilterKey>
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<string>
ladderRules: Map<string, MonitorRule>
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<string, number>()
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<string, number>()
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 (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`border-l-2 ${tierBorder(tier.boards)} rounded-r-lg bg-surface/50`}
>
{/* 头部 */}
<button
onClick={() => setOpen(v => !v)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-surface/80 transition-colors"
>
<Flame className={`h-3.5 w-3.5 ${tier.boards >= 5 ? 'text-orange-500' : tier.boards >= 3 ? 'text-yellow-500' : 'text-muted'}`} />
<span className={`text-sm font-bold tabular-nums ${tierTextCls(tier.boards)}`}>{tierLabel(tier.boards, direction)}<span className="text-muted/40 mx-1">·</span>{luCount}</span>
{(showBroken && brCount > 0) || (showFailed && faCount > 0) ? (
<span className="text-[11px] text-muted/60">
{showBroken && brCount > 0 && <span className="text-purple-400">{brCount}{brokenBadge}</span>}
{showBroken && brCount > 0 && showFailed && faCount > 0 && <span className="text-muted/40"> · </span>}
{showFailed && faCount > 0 && <span className="text-muted/80">{faCount}{failedBadge}</span>}
</span>
) : null}
<ChevronDown
className={`h-3.5 w-3.5 ml-auto text-muted transition-transform ${open ? 'rotate-180' : ''}`}
/>
</button>
{/* 股票列表 */}
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
{/* 分组统计 */}
{hasGroupStats && (
<div className="px-3 pt-1 pb-2 space-y-1">
{groupConceptStats.length > 0 && (
<div className="flex flex-wrap gap-1 items-center">
<span className="text-[9px] tracking-wider text-yellow-400/70 mr-0.5">概念</span>
{groupConceptStats.slice(0, 20).map(([name, count]) => {
const isSelected = selectedTag?.fieldKey === 'concept' && selectedTag?.tag === name
return (
<button
key={name}
onClick={() => onSelectTag(isSelected ? null : { fieldKey: 'concept', tag: name })}
className="text-[10px] px-1.5 py-0.5 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
color: isSelected ? '#fff' : 'rgba(250,204,21,0.8)',
backgroundColor: isSelected ? 'rgba(234,179,8,0.7)' : 'rgba(234,179,8,0.12)',
outline: isSelected ? '1px solid rgba(250,204,21,0.8)' : 'none',
outlineOffset: 1,
}}
>
{name}<span className="ml-0.5" style={{ opacity: isSelected ? 0.8 : 0.6 }}>{count}</span>
</button>
)
})}
</div>
)}
{groupIndustryStats.length > 0 && (
<div className="flex flex-wrap gap-1 items-center">
<span className="text-[9px] tracking-wider text-blue-400/70 mr-0.5">行业</span>
{groupIndustryStats.slice(0, 20).map(([name, count]) => {
const isSelected = selectedTag?.fieldKey === 'industry' && selectedTag?.tag === name
return (
<button
key={name}
onClick={() => onSelectTag(isSelected ? null : { fieldKey: 'industry', tag: name })}
className="text-[10px] px-1.5 py-0.5 rounded-sm whitespace-nowrap cursor-pointer hover:brightness-110 transition-all"
style={{
color: isSelected ? '#fff' : 'rgba(96,165,250,0.8)',
backgroundColor: isSelected ? 'rgba(59,130,246,0.7)' : 'rgba(59,130,246,0.12)',
outline: isSelected ? '1px solid rgba(96,165,250,0.8)' : 'none',
outlineOffset: 1,
}}
>
{name}<span className="ml-0.5" style={{ opacity: isSelected ? 0.8 : 0.6 }}>{count}</span>
</button>
)
})}
</div>
)}
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 px-3 pb-3">
{[...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 => (
<StockCard
key={`${s.symbol}-${s.status}`}
stock={s}
extFields={extFields}
direction={direction}
sealMode={sealMode}
monitored={monitoredSymbols.has(s.symbol)}
monitorRule={ladderRules.get(s.symbol)}
onMonitorChange={onMonitorChange}
hasDepth={hasDepth}
onClick={() => onStockClick(s.symbol, s.name ?? undefined)}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
// ===== 字段配置弹窗 =====
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 (
<label className="flex items-center justify-between gap-2 text-xs cursor-pointer">
<span className="text-secondary">{label}</span>
<span
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
style={{ width: 32, height: 18, position: 'relative', borderRadius: 9, transition: 'background-color 0.15s', cursor: 'pointer' }}
className={checked ? 'bg-accent/60' : 'bg-border'}
>
<span
style={{
position: 'absolute', top: 2, left: 0, width: 14, height: 14, borderRadius: '50%', background: '#fff',
transition: 'transform 0.15s', transform: checked ? 'translateX(16px)' : 'translateX(2px)',
}}
/>
</span>
</label>
)
}
/** 数字输入行 */
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 (
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-secondary">{label}</span>
<input
type="number"
min={min}
max={max}
value={value ?? ''}
onChange={e => {
let v = e.target.value ? Number(e.target.value) : undefined
if (v != null) {
if (min != null && v < min) v = min
if (max != null && v > max) v = max
}
onChange(v)
}}
placeholder={placeholder}
className="w-16 h-7 bg-elevated border border-border rounded text-xs text-foreground text-center px-1 placeholder:text-muted focus:outline-none focus:border-accent/50"
/>
</label>
)
}
/** 字段选择下拉 */
function FieldSelect({ value, onChange, options }: {
value: string; onChange: (v: string) => void; options: SchemaOption[]
}) {
return (
<div className="flex-1 min-w-0 overflow-hidden">
<select
value={value}
onChange={e => onChange(e.target.value)}
className="w-full h-7 bg-elevated border border-border rounded text-xs text-foreground px-2 focus:outline-none focus:border-accent/50"
>
<option value="">不显示</option>
{options.map(o => (
<optgroup key={o.id} label={o.label}>
{o.columns.map(col => (
<option key={`${o.id}.${col.name}`} value={`${o.id}.${col.name}`}>
{col.label || col.name}
</option>
))}
</optgroup>
))}
</select>
</div>
)
}
/** 扩展字段配置区(概念/行业) */
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<ExtColumnDisplayConfig>) => {
onChange({ field, display: { displayMode: 'tag', ...cfg, ...patch } })
}
return (
<div className="space-y-2.5">
<div className="flex items-center gap-2">
<span className="text-xs text-secondary shrink-0 w-16">选择字段</span>
<FieldSelect value={field} onChange={v => onChange(v ? { field: v, display: { displayMode: 'tag' } } : undefined)} options={options} />
</div>
{!field ? null : (
<>
<div className="flex items-center gap-2">
<span className="text-xs text-secondary shrink-0 w-16">显示模式</span>
<div className="flex flex-1 min-w-0 rounded overflow-hidden border border-border">
<button onClick={() => updateDisplay({ displayMode: 'tag' })} className={`flex-1 py-1 text-xs transition-colors ${displayMode === 'tag' ? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary'}`}>标签</button>
<button onClick={() => updateDisplay({ displayMode: 'text' })} className={`flex-1 py-1 text-xs transition-colors border-l border-border ${displayMode === 'text' ? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary'}`}>文本</button>
</div>
</div>
{displayMode === 'tag' && (
<div>
<div className="flex items-center gap-2">
<span className="text-xs text-secondary shrink-0 w-16">分隔符</span>
<input
type="text"
value={cfg?.separator ?? ''}
onChange={e => 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"
/>
</div>
<div className="text-[10px] text-muted mt-1" style={{ paddingLeft: 72 }}>
留空自动识别:、 , ; -
</div>
</div>
)}
{displayMode === 'tag' && (
<div className="flex items-center gap-2">
<span className="text-xs text-secondary shrink-0 w-16">显示前N个</span>
<input
type="number" min={0}
value={cfg?.maxTags ?? ''}
onChange={e => {
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"
/>
</div>
)}
{displayMode === 'tag' && (cfg?.maxTags ?? 0) > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-secondary shrink-0 w-16">显示位置</span>
<div className="flex flex-wrap gap-1">
{Array.from({ length: cfg!.maxTags! }, (_, i) => {
const hidden = cfg?.hiddenIndices?.includes(i)
return (
<button
key={i}
onClick={() => {
const cur = cfg?.hiddenIndices ?? []
const next = hidden ? cur.filter(x => x !== i) : [...cur, i]
updateDisplay({ hiddenIndices: next.length ? next : undefined })
}}
className={`w-6 h-6 rounded text-[10px] font-medium transition-colors ${hidden ? 'bg-elevated text-muted line-through' : 'bg-accent/15 text-accent'}`}
>{i + 1}</button>
)
})}
</div>
</div>
)}
{displayMode === 'tag' && (
<div className="flex items-center gap-2">
<span className="text-xs text-secondary shrink-0 w-16">排列方向</span>
<div className="flex flex-1 min-w-0 rounded overflow-hidden border border-border">
<button onClick={() => updateDisplay({ tagLayout: 'horizontal' })} className={`flex-1 py-1 text-xs transition-colors ${(cfg?.tagLayout ?? 'horizontal') === 'horizontal' ? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary'}`}></button>
<button onClick={() => updateDisplay({ tagLayout: 'vertical' })} className={`flex-1 py-1 text-xs transition-colors border-l border-border ${cfg?.tagLayout === 'vertical' ? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary'}`}></button>
</div>
</div>
)}
<div className="flex justify-end">
<button onClick={() => onChange({ field, display: { displayMode: 'tag' } })} className="text-[10px] text-muted hover:text-foreground">恢复默认</button>
</div>
</>
)}
</div>
)
}
/** 炸板/断板配置区 */
function BrokenFailedSection({ bf, onChange }: {
bf: BrokenFailedConfig
onChange: (bf: BrokenFailedConfig) => void
}) {
const update = (patch: Partial<BrokenFailedConfig>) => onChange({ ...bf, ...patch })
return (
<div className="space-y-3">
{/* 炸板 */}
<div className="space-y-2">
<span className="text-[10px] font-semibold text-purple-400 uppercase tracking-wider">炸板</span>
<Toggle label="显示炸板股票" checked={bf.brokenShow ?? true} onChange={v => update({ brokenShow: v })} />
<Toggle label="计入炸板数量" checked={bf.brokenCount ?? true} onChange={v => update({ brokenCount: v })} />
<NumInput label="最低板数(含)" value={bf.brokenMinBoards ?? 0} onChange={v => update({ brokenMinBoards: v ?? 0 })} min={0} max={50} placeholder="0=不限" />
{(bf.brokenMinBoards ?? 0) > 0 && (
<span className="text-[10px] text-muted pl-1">低于 {bf.brokenMinBoards} 板的炸板不显示也不计数</span>
)}
</div>
<div className="h-px bg-border" />
{/* 断板 */}
<div className="space-y-2">
<span className="text-[10px] font-semibold text-yellow-500 uppercase tracking-wider">断板</span>
<Toggle label="显示断板股票" checked={bf.failedShow ?? true} onChange={v => update({ failedShow: v })} />
<Toggle label="计入断板数量" checked={bf.failedCount ?? true} onChange={v => update({ failedCount: v })} />
<NumInput label="最低板数(含)" value={bf.failedMinBoards ?? 0} onChange={v => update({ failedMinBoards: v ?? 0 })} min={0} max={50} placeholder="0=不限" />
{(bf.failedMinBoards ?? 0) > 0 && (
<span className="text-[10px] text-muted pl-1">低于 {bf.failedMinBoards} 板的断板不显示也不计数</span>
)}
</div>
</div>
)
}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-surface border border-border rounded-lg shadow-xl max-w-[95vw] overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* 头部 */}
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<span className="text-sm font-medium">配置</span>
<button onClick={onClose} className="p-0.5 text-muted hover:text-foreground"><X className="h-4 w-4" /></button>
</div>
{/* 三列平铺 */}
<div className="flex gap-0 border-b border-border px-2 overflow-hidden">
<div className="flex-1 min-w-0 p-3 border-r border-border" style={{ minWidth: 180 }}>
<span className="text-[10px] font-semibold text-sky-400 uppercase tracking-wider mb-2 block">概念</span>
<ExtFieldSection item={draft.concept} onChange={v => setDraft(d => ({ ...d, concept: v }))} options={options} />
<div className="h-px bg-border my-3" />
<Toggle label="显示概念分布统计" checked={draft.showConceptStats ?? true} onChange={v => setDraft(d => ({ ...d, showConceptStats: v }))} />
<div className="h-px bg-border my-2" />
<Toggle label="显示分组概念统计" checked={draft.showConceptGroupStats ?? false} onChange={v => setDraft(d => ({ ...d, showConceptGroupStats: v }))} />
</div>
<div className="flex-1 min-w-0 p-3 border-r border-border" style={{ minWidth: 180 }}>
<span className="text-[10px] font-semibold text-blue-400 uppercase tracking-wider mb-2 block">行业</span>
<ExtFieldSection item={draft.industry} onChange={v => setDraft(d => ({ ...d, industry: v }))} options={options} />
<div className="h-px bg-border my-3" />
<Toggle label="显示行业分布统计" checked={draft.showIndustryStats ?? true} onChange={v => setDraft(d => ({ ...d, showIndustryStats: v }))} />
<div className="h-px bg-border my-2" />
<Toggle label="显示分组行业统计" checked={draft.showIndustryGroupStats ?? false} onChange={v => setDraft(d => ({ ...d, showIndustryGroupStats: v }))} />
</div>
<div className="flex-1 min-w-0 p-3" style={{ minWidth: 160 }}>
<span className="text-[10px] font-semibold text-muted uppercase tracking-wider mb-2 block">炸板/断板</span>
<BrokenFailedSection bf={{ ...DEFAULT_BF, ...draft.bf }} onChange={v => setDraft(d => ({ ...d, bf: v }))} />
</div>
</div>
{/* 底部按钮 */}
<div className="flex justify-end gap-2 px-4 py-3">
<button onClick={onClose} className="px-3 py-1.5 text-xs text-secondary hover:text-foreground">取消</button>
<button
onClick={() => { onSave(draft); onClose() }}
className="px-3 py-1.5 text-xs bg-accent/15 text-accent rounded hover:bg-accent/25"
>保存</button>
</div>
</motion.div>
</div>
)
}
// ===== 主页面 =====
export function LimitUpLadder() {
const [asOf, setAsOf] = useState('')
const [direction, setDirection] = useState<Direction>(() => storage.limitLadderDirection.get('up'))
const [sealMode, setSealMode] = useState<'vol' | 'amount'>(() => storage.limitLadderSealMode.get('vol'))
const [filterKeys, setFilterKeys] = useState<Set<FilterKey>>(loadFilterKeys)
const [extFields, setExtFields] = useState<ExtFieldConfig>(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<string, typeof all[number]>()
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<string | null>(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 (
<div className="flex items-center justify-center h-full">
<RefreshCw className="h-5 w-5 animate-spin text-muted" />
</div>
)
}
const dateValue = displayDate || new Date().toISOString().slice(0, 10)
if (!data || rawTiers.length === 0) {
return (
<div className="flex flex-col h-full">
<PageHeader title={direction === 'down' ? '连跌梯队' : '连板梯队'} />
<EmptyState icon={Flame} title={direction === 'down' ? '暂无连跌数据' : '暂无连板数据'} hint={direction === 'down' ? '该日期无跌停股或 enriched 数据未就绪' : '该日期无涨停股或 enriched 数据未就绪'} />
</div>
)
}
return (
<div className="flex flex-col h-full">
<PageHeader
title={direction === 'down' ? '连跌梯队' : '连板梯队'}
titleExtra={
<div className="flex items-center gap-2">
<SealedBadge
degraded={sealedDegrade.degraded}
hasDepth={sealedDegrade.hasDepth}
isHistorical={sealedDegrade.isHistorical}
sealedReady={sealedDegrade.sealedReady}
sealedCountsUp={data?.sealed_counts_up}
sealedCountsDown={data?.sealed_counts_down}
rawUp={data?.counts_raw?.up}
rawDown={data?.counts_raw?.down}
/>
{/* 涨跌停切换(胶囊式): 点击切换方向, 当前方向有背景 */}
<div className="flex items-center rounded-full bg-elevated/60 p-0.5">
<button
onClick={() => direction !== 'up' && toggleDirection('up')}
className={`flex items-center gap-1 px-2.5 h-7 rounded-full text-xs tabular-nums transition-all ${
direction === 'up'
? 'bg-bull/15 text-bull font-semibold'
: 'text-muted hover:text-bull/70'
}`}
>
<span>涨停</span>
<span>{data?.counts?.up ?? 0}</span>
</button>
<button
onClick={() => direction !== 'down' && toggleDirection('down')}
className={`flex items-center gap-1 px-2.5 h-7 rounded-full text-xs tabular-nums transition-all ${
direction === 'down'
? 'bg-bear/15 text-bear font-semibold'
: 'text-muted hover:text-bear/70'
}`}
>
<span>跌停</span>
<span>{data?.counts?.down ?? 0}</span>
</button>
</div>
</div>
}
right={
<div className="flex items-center gap-1">
{/* 封单模式: 成交量/金额(仅 sealed 就绪时显示) — 胶囊式 */}
{data?.sealed_ready && (
<>
<div className="flex items-center rounded-full bg-elevated/60 p-0.5">
{(['vol', 'amount'] as const).map(m => (
<button
key={m}
onClick={() => {
setSealMode(m)
storage.limitLadderSealMode.set(m)
}}
className={`flex items-center px-2 py-1 rounded-full text-xs transition-all ${
sealMode === m
? 'bg-accent/15 text-accent font-medium'
: 'text-muted hover:text-secondary'
}`}
>
{m === 'vol' ? '封单量' : '封单额'}
</button>
))}
</div>
<div className="w-px h-4 bg-border mx-1" />
</>
)}
{/* 状态组: 涨停/炸板/断板 或 跌停/翘板/止跌 */}
{statusTabs(direction).map(tab => (
<button
key={tab.key}
onClick={() => toggleFilter(tab.key)}
className={`px-2 py-1 text-xs transition-colors ${
filterKeys.has(tab.key)
? 'bg-accent/15 text-accent font-medium'
: 'text-secondary hover:text-foreground hover:bg-surface'
}`}
>
{tab.label}
</button>
))}
<div className="w-px h-4 bg-border mx-1" />
{/* 显示组: 概念/行业 */}
<button
onClick={toggleConcept}
className={`px-2 py-1 text-xs transition-colors ${
showConcept
? 'bg-yellow-500/15 text-yellow-400 font-medium'
: 'text-secondary hover:text-foreground hover:bg-surface'
}`}
>
概念
</button>
<button
onClick={toggleIndustry}
className={`px-2 py-1 text-xs transition-colors ${
showIndustry
? 'bg-blue-500/15 text-blue-400 font-medium'
: 'text-secondary hover:text-foreground hover:bg-surface'
}`}
>
行业
</button>
<div className="w-px h-4 bg-border mx-1" />
{/* 板块组 */}
{BOARD_TABS.map(tab => (
<button
key={tab.key}
onClick={() => toggleFilter(tab.key)}
className={`px-2 py-1 text-xs transition-colors ${
filterKeys.has(tab.key)
? 'bg-accent/15 text-accent font-medium'
: 'text-secondary hover:text-foreground hover:bg-surface'
}`}
>
{tab.label}
</button>
))}
<div className="w-px h-4 bg-border mx-1" />
<button
onClick={() => setShowExtConfig(true)}
className="p-1.5 hover:bg-surface text-muted hover:text-accent"
title="配置"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
<button
onClick={() => refetch()}
disabled={isFetching}
className="p-1.5 hover:bg-surface text-muted disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
</button>
</div>
}
/>
{/* 总览条 + 日期 */}
<OverviewBar tiers={tiers} dateValue={dateValue} onDateChange={setAsOf} filterKeys={filterKeys} bf={extFields.bf} direction={direction} />
{/* 概念统计 */}
{(extFields.showConceptStats ?? true) && (
<TagStats
title="概念分布"
tiers={tiers}
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
fieldKey="concept"
color={{ text: [250, 204, 21], bg: [234, 179, 8] }}
selectedTag={selectedTag}
onSelect={handleSelectTag}
direction={direction}
/>
)}
{/* 行业统计 */}
{(extFields.showIndustryStats ?? true) && (
<TagStats
title="行业分布"
tiers={tiers}
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
fieldKey="industry"
color={{ text: [96, 165, 250], bg: [59, 130, 246] }}
selectedTag={selectedTag}
onSelect={handleSelectTag}
direction={direction}
/>
)}
{/* 梯队列表 */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
{tiers.map(t => (
<TierGroup
key={t.boards}
tier={t}
defaultOpen={t.boards >= 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}
/>
))}
</div>
{/* 个股K线弹窗 */}
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => setPreviewSymbol(null)}
/>
{/* 字段配置弹窗 */}
<AnimatePresence>
{showExtConfig && (
<ExtConfigDialog
fields={extFields}
onSave={handleSaveExtFields}
onClose={() => setShowExtConfig(false)}
/>
)}
</AnimatePresence>
</div>
)
}