import { useState, useCallback, useEffect, createContext, useContext } from 'react' import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query' import { Activity, Wifi, Flame, Zap, Webhook, ChevronDown, } from 'lucide-react' import { usePreferences, useQuoteStatus, useQuoteInterval, useCapabilities, } from '@/lib/useSharedQueries' import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { useCardFlash, cardFlashCls } from '@/lib/useCardFlash' import { toast } from '@/components/Toast' import { DepthConfigContent } from '@/components/data/DepthConfigCard' // 卡片定位锚点: highlight= 时该卡片滚动到视口中央并闪烁高亮。 // 其他页面用 /settings?tab=monitoring&highlight= 精确引导用户到某张卡片。 const HighlightContext = createContext('') // 页面 → 显示名 const PAGE_LABELS: Record = { 'overview-market': '看板', watchlist: '自选页', 'limit-ladder': '连板梯队', } // ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) ===== export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {}) { const qc = useQueryClient() const { data: prefs } = usePreferences() const { data: caps } = useCapabilities() const { data: quoteStatus } = useQuoteStatus() const { data: intervalData } = useQuoteInterval() const updateInterval = useUpdateQuoteInterval() const toggleQuote = useToggleRealtimeQuotes() // 实时模式以 quote_status 为准 (数据源无关): full_market=全市场 / none=不可用 const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false // 分时图实时刷新间隔 (秒), 与后端 [3,60] clamp 对齐; 默认 6 const intradayInterval = prefs?.minute_intraday_refresh_interval ?? 6 // 滑块本地草稿: 拖动时即时反馈, 停顿 2s 后落库 (与行情轮询滑块一致) const [intradayIntervalDraft, setIntradayIntervalDraft] = useState(intradayInterval) // 盘中分钟增量 (Expert 专有): 间隔 (秒), 与后端 [3,120] clamp 对齐; 默认 6 const minuteRefreshInterval = prefs?.minute_refresh_interval ?? 6 const [minuteRefreshIntervalDraft, setMinuteRefreshIntervalDraft] = useState(minuteRefreshInterval) // 盘中增量服务状态 (15s 轮询; 无服务时 available=false) const refreshStatus = useQuery({ queryKey: ['minute-refresh-status'], queryFn: api.minuteRefreshStatus, refetchInterval: 15000, }) const refreshPages = prefs?.sse_refresh_pages ?? {} const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false const hasDepth = !!caps?.capabilities?.['depth5.batch'] // 全量分钟 = intraday.universe 能力 (TickFlow Expert 专有): 标的池单请求拉全市场当日分钟, // 修复轮的 intraday.batch 与其同档, 见后端 minute_refresh 服务 const hasFullMinuteCap = !!caps?.capabilities?.['intraday.universe'] const rs = refreshStatus.data // 新建监控规则时默认勾选的推送渠道 (全局默认值数组, 单条规则可独立修改) const webhookDefaultChannels = prefs?.webhook_default_channels ?? [] const isRunning = quoteStatus?.running ?? false const isTrading = quoteStatus?.is_trading_hours ?? false // 管道/数据修正运行期间实时行情被临时暂停 — 此时禁止开启 const isPaused = quoteStatus?.paused ?? false const interval = intervalData?.interval ?? 6 const minInterval = intervalData?.min_interval ?? 6 const maxInterval = intervalData?.max_interval ?? 60 const [intervalDraft, setIntervalDraft] = useState(interval) const feishuWebhookUrl = prefs?.feishu_webhook_url ?? '' const feishuWebhookSecret = prefs?.feishu_webhook_secret ?? '' const [feishuDraft, setFeishuDraft] = useState(feishuWebhookUrl) const [feishuSecretDraft, setFeishuSecretDraft] = useState(feishuWebhookSecret) const [feishuError, setFeishuError] = useState('') // 企业微信 webhook const wecomWebhookUrl = prefs?.wecom_webhook_url ?? '' const [wecomDraft, setWecomDraft] = useState(wecomWebhookUrl) const [wecomError, setWecomError] = useState('') // 企业微信智能机器人 (BotID + Secret, 长连接通道) const wecomBotId = prefs?.wecom_bot_id ?? '' const wecomBotSecret = prefs?.wecom_bot_secret ?? '' const wecomBotEnabled = prefs?.wecom_bot_enabled ?? false const [botIdDraft, setBotIdDraft] = useState(wecomBotId) const [botSecretDraft, setBotSecretDraft] = useState(wecomBotSecret) const [botError, setBotError] = useState('') const [botStatus, setBotStatus] = useState<{connected: boolean; last_error: string} | null>(null) // 飞书渠道配置区展开态 (推送通知卡片内) const [channelOpen, setChannelOpen] = useState(false) // 企业微信渠道配置区展开态 const [wecomOpen, setWecomOpen] = useState(false) // 智能机器人配置区展开态 const [botOpen, setBotOpen] = useState(false) useEffect(() => { setFeishuDraft(feishuWebhookUrl) setFeishuSecretDraft(feishuWebhookSecret) }, [feishuWebhookUrl, feishuWebhookSecret]) useEffect(() => { setWecomDraft(wecomWebhookUrl) }, [wecomWebhookUrl]) useEffect(() => { setBotIdDraft(wecomBotId) setBotSecretDraft(wecomBotSecret) }, [wecomBotId, wecomBotSecret]) const save = useCallback(async (cfg: Record) => { try { await api.updateRealtimeMonitorConfig(cfg) qc.invalidateQueries({ queryKey: QK.preferences }) } catch (e) { // 忽略 — Toast 已在 request 层处理 } }, [qc]) const handleToggleQuote = useCallback(async (enabled: boolean) => { await toggleQuote.mutateAsync(enabled) qc.invalidateQueries({ queryKey: QK.preferences }) qc.invalidateQueries({ queryKey: QK.quoteStatus }) }, [toggleQuote, qc]) const toggleLimitLadderMonitor = useCallback(async (enabled: boolean) => { await api.updateLimitLadderMonitor(enabled) qc.invalidateQueries({ queryKey: QK.preferences }) }, [qc]) // 勾选/取消勾选某个默认推送渠道 (飞书 / 企业微信 各自独立) const toggleDefaultChannel = useCallback(async (ch: string, enabled: boolean) => { const cur = prefs?.webhook_default_channels ?? [] const next = enabled ? [...cur, ch] : cur.filter(c => c !== ch) await api.updateWebhookDefaultChannels(next) qc.invalidateQueries({ queryKey: QK.preferences }) }, [qc, prefs]) const saveFeishuWebhook = useMutation({ mutationFn: ({ url, secret }: { url: string; secret: string }) => api.updateFeishuWebhook(url, secret), onSuccess: () => { setFeishuError('') toast('飞书 Webhook 已保存', 'success') qc.invalidateQueries({ queryKey: QK.preferences }) }, onError: (err: any) => setFeishuError(String(err?.message ?? '保存失败')), }) const FEISHU_PREFIX = 'https://open.feishu.cn/open-apis/bot/v2/hook/' const submitFeishu = useCallback(() => { const url = feishuDraft.trim() const secret = feishuSecretDraft.trim() if (url && !url.startsWith(FEISHU_PREFIX)) { setFeishuError('地址需以 ' + FEISHU_PREFIX + ' 开头') return } saveFeishuWebhook.mutate({ url, secret }) }, [feishuDraft, feishuSecretDraft, saveFeishuWebhook]) const saveWecomWebhook = useMutation({ mutationFn: (url: string) => api.updateWecomWebhook(url), onSuccess: () => { setWecomError('') toast('企业微信 Webhook 已保存', 'success') qc.invalidateQueries({ queryKey: QK.preferences }) }, onError: (err: any) => setWecomError(String(err?.message ?? '保存失败')), }) const WECOM_PREFIX = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send' const submitWecom = useCallback(() => { const url = wecomDraft.trim() // 允许完整 URL 或纯 key (36位UUID样式) if (url && !url.startsWith(WECOM_PREFIX) && url.length < 20) { setWecomError('请输入完整 Webhook 地址或纯 key (至少 20 位)') return } saveWecomWebhook.mutate(url) }, [wecomDraft, saveWecomWebhook]) // 智能机器人 (BotID + Secret) 保存 → 后端立即重建连接 const saveWecomBot = useMutation({ mutationFn: ({ botId, secret }: { botId: string; secret: string }) => api.updateWecomBot(botId, secret, true), onSuccess: (data) => { setBotError('') toast('智能机器人凭证已保存, 正在连接…', 'success') setBotStatus({ connected: data.wecom_bot_status?.connected ?? false, last_error: data.wecom_bot_status?.last_error ?? '', }) qc.invalidateQueries({ queryKey: QK.preferences }) }, onError: (err: any) => setBotError(String(err?.message ?? '保存失败')), }) const submitBot = useCallback(() => { saveWecomBot.mutate({ botId: botIdDraft.trim(), secret: botSecretDraft.trim() }) }, [botIdDraft, botSecretDraft, saveWecomBot]) // 智能机器人长连接开关(不改动凭证): 开启→连接, 关闭→断开 const toggleBotConnection = useMutation({ mutationFn: (enabled: boolean) => api.toggleWecomBot(enabled), onSuccess: (data) => { setBotStatus({ connected: data.wecom_bot_status?.connected ?? false, last_error: data.wecom_bot_status?.last_error ?? '', }) qc.invalidateQueries({ queryKey: QK.preferences }) }, }) const runFix = useMutation({ mutationFn: () => api.runLimitLadderFix(), onSuccess: (data) => { toast(data.msg, data.ok ? 'success' : 'error') // 修正后连板梯队数据变了, 刷新相关缓存 qc.invalidateQueries({ queryKey: ['limit-ladder'] }) }, onError: () => toast('修正请求失败', 'error'), }) useEffect(() => { setIntervalDraft(interval) }, [interval]) useEffect(() => { if (intervalDraft === interval) return const t = window.setTimeout(() => { updateInterval.mutate(intervalDraft) }, 2000) return () => window.clearTimeout(t) }, [intervalDraft, interval, updateInterval]) // 分时刷新间隔: 服务端值变化时同步本地草稿 useEffect(() => { setIntradayIntervalDraft(intradayInterval) }, [intradayInterval]) // 分时刷新间隔: 草稿与已保存值不同时, 2s 防抖落库 useEffect(() => { if (intradayIntervalDraft === intradayInterval) return const t = window.setTimeout(() => { save({ minute_intraday_refresh_interval: intradayIntervalDraft }) }, 2000) return () => window.clearTimeout(t) }, [intradayIntervalDraft, intradayInterval, save]) // 盘中增量间隔: 服务端值变化时同步本地草稿 useEffect(() => { setMinuteRefreshIntervalDraft(minuteRefreshInterval) }, [minuteRefreshInterval]) // 盘中增量间隔: 草稿与已保存值不同时, 2s 防抖落库 useEffect(() => { if (minuteRefreshIntervalDraft === minuteRefreshInterval) return const t = window.setTimeout(() => { save({ minute_refresh_interval: minuteRefreshIntervalDraft }) }, 2000) return () => window.clearTimeout(t) }, [minuteRefreshIntervalDraft, minuteRefreshInterval, save]) return (
{/* ========== 左列 ========== */}
{/* 行情状态 — 开关 + 间隔 */}
轮询间隔
每轮拉取全市场行情的时间间隔
{intervalDraft < 1 ? intervalDraft.toFixed(1) : intervalDraft.toFixed(0)}s
setIntervalDraft(parseFloat(e.target.value))} className="flex-1 h-1 accent-accent cursor-pointer" /> {intervalDraft !== interval ? '2秒后保存' : `${minInterval}s — ${maxInterval}s`}

选择哪些页面跟随 SSE 实时刷新数据。关闭的页面不会被推送, 但行情轮询和策略监控不受影响。

{Object.entries(PAGE_LABELS).map(([key, label]) => ( save({ sse_refresh_pages: { ...refreshPages, [key]: v } })} /> ))}
{/* 自选列表分时图实时刷新 (默认关闭, 开启后盘中按设定间隔轮询刷新分时数据) */} save({ minute_intraday_refresh: v })} />
刷新间隔
间隔越短更新越及时, 但越耗数据源配额 (rpm)
{intradayIntervalDraft}s
setIntradayIntervalDraft(parseInt(e.target.value, 10))} className="flex-1 h-1 accent-accent cursor-pointer" /> {intradayIntervalDraft !== intradayInterval ? '2秒后保存' : '3s — 60s'}
{/* ========== 右列 ========== */}
{/* 全量分钟: 盘中全市场分钟落盘, 按能力路由 (TickFlow Expert 或声明 full_minute 的插件/自定义源) */} save({ minute_refresh_enabled: v })} disabled={!hasFullMinuteCap} />
刷新间隔
交易时段内全市场分钟K增量落盘的间隔; 稳态单请求增量, 冷启动/断档自动全天回补
{minuteRefreshIntervalDraft >= 60 && minuteRefreshIntervalDraft % 60 === 0 ? `${minuteRefreshIntervalDraft / 60}m` : `${minuteRefreshIntervalDraft}s`}
setMinuteRefreshIntervalDraft(parseInt(e.target.value, 10))} className="flex-1 h-1 accent-accent cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" /> {minuteRefreshIntervalDraft !== minuteRefreshInterval ? '2秒后保存' : '3s — 120s'}
{rs?.available && rs.rounds != null && rs.rounds > 0 && (
已 {rs.rounds} 轮 · 最近 {rs.last_symbols} 标的 / {rs.last_rows} 行 / {rs.last_requests} 请求 {rs.last_round_ms != null ? ` · ${(rs.last_round_ms / 1000).toFixed(1)}s` : ''} {rs.last_error ? ` · ${rs.last_error}` : ''}
)}
{/* 连板梯队降级修正 */} runFix.mutate()} disabled={runFix.isPending} className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] bg-accent/15 text-accent hover:bg-accent/25 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > {runFix.isPending ? '修正中…' : '立即修正'} ) : undefined} > {hasDepth ? ( <>

通过五档盘口实时修正真假涨停/跌停。真封板显示封单量,假涨停(收盘价=涨停价但卖一有量)归入炸板。 盘中按设定间隔轮询,收盘后自动定版。

五档盘口配置
) : ( )}
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。 飞书 / 企业微信。 每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}

监控规则命中后,可把告警推送到外部。勾选渠道作为新建规则的默认推送, 单条规则仍可在编辑页独立修改。

{/* 渠道列表 — 每行一个渠道, 勾选默认 + 点行展开地址配置 */}
{/* 飞书 (可用): 勾选默认 + 展开地址配置 */}
setChannelOpen(o => !o)} className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60" > { e.stopPropagation(); toggleDefaultChannel('feishu', e.target.checked) }} onClick={e => e.stopPropagation()} title="作为新建规则的默认推送渠道" className="h-3 w-3 accent-accent cursor-pointer" /> 飞书 群推送 Webhook {webhookDefaultChannels.includes('feishu') && ( 默认 )} {feishuWebhookUrl ? '已配置' : '未配置'}
{/* 飞书地址配置 — 行内展开 */} {channelOpen && (
{feishuError && (
{feishuError}
)}
{feishuWebhookUrl && ( ● 已配置 )}
如何获取飞书 Webhook 地址?
  1. 打开飞书,进入目标群聊 → 群设置 → 群推送 Webhook
  2. 点击「添加机器人」→ 选择「自定义机器人
  3. 填写机器人名称后添加,复制生成的 Webhook 地址
  4. 安全设置若启用了「签名校验」,把密钥一并复制填到「签名密钥」框
  5. 粘贴到上方输入框并保存

📖 官方文档: 自定义机器人使用指南 ↗

)}
{/* 企业微信群推送 Webhook (可用): 与飞书并列, 勾选默认 + 展开地址配置 */}
setWecomOpen(o => !o)} className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60" > { e.stopPropagation(); toggleDefaultChannel('wecom', e.target.checked) }} onClick={e => e.stopPropagation()} title="作为新建规则的默认推送渠道" className="h-3 w-3 accent-accent cursor-pointer" /> 企业微信 群推送 Webhook {webhookDefaultChannels.includes('wecom') && ( 默认 )} {wecomWebhookUrl ? '已配置' : '未配置'}
{wecomOpen && (
{wecomError && (
{wecomError}
)}
{wecomWebhookUrl && ( ● 已配置 )}
如何获取企业微信 Webhook 地址?
  1. 打开企业微信,进入目标群聊 → 右上角「...」→ 群推送 Webhook
  2. 点击「添加」→ 选择「消息推送」→ 填写名字
  3. 复制生成的 Webhook 地址(含 key 参数),粘贴到上方输入框
  4. 也可只复制 key 参数部分(= 后面的内容)填入
  5. 企业微信群的消息可同步到绑定的个人微信,实现"微信推送"

📖 官方文档: 群推送 Webhook 使用指南 ↗

)}
{/* 企业微信智能机器人 (BotID + Secret): 长连接通道, 与群推送 Webhook 并列 */}
setBotOpen(o => !o)} className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60" > { e.stopPropagation(); toggleBotConnection.mutate(e.target.checked) }} onClick={e => e.stopPropagation()} disabled={!wecomBotId || toggleBotConnection.isPending} title="开启后建立长连接保活, 关闭则断开" className="h-3 w-3 accent-accent cursor-pointer disabled:opacity-40" /> 企业微信 智能机器人 {wecomBotId ? (botStatus?.connected ? '已连接' : (wecomBotEnabled ? '连接中' : '已配置')) : '未配置'}
{botOpen && (

勾选卡片左侧开关可启用长连接保活(开启后后端持续保持与企业微信的 WebSocket 连接)。保存凭证后需勾选才会连接, 取消勾选则立即断开。

{botError && (
{botError}
)} {botStatus?.last_error && !botError && (
连接异常: {botStatus.last_error}
)}
{wecomBotId && ( ● 已配置 )}
如何获取 BotID 和 Secret?
  1. 登录企业微信管理后台 → 应用管理 → 智能机器人 → 创建机器人
  2. 填写名称、头像后进入机器人配置页
  3. 开启「API 模式」→ 选择「长连接」方式(另一项"回调URL"需公网IP)
  4. 页面显示 BotIDSecret, 复制填到上方输入框

💡 智能机器人支持 @交互和流式回复, 与群推送 Webhook(单向推送)互补。 保存后后端会自动建立 WebSocket 长连接保活。

📖 官方文档: 智能机器人长连接 ↗

)}
) } // ===== ToggleRow ===== function ToggleRow({ label, desc, checked, onChange, icon: Icon, disabled, }: { label: string desc: string checked: boolean onChange: (v: boolean) => void icon?: React.ComponentType<{ className?: string }> disabled?: boolean }) { return (
{Icon && }
{label}
{desc}
) } // ===== 通用卡片 ===== interface CardProps { icon: React.ComponentType<{ className?: string }> title: string badge?: string right?: React.ReactNode children: React.ReactNode } function Card({ icon: Icon, title, badge, right, children, anchor }: CardProps & { anchor?: string }) { const highlight = useContext(HighlightContext) const { ref, flash } = useCardFlash(anchor ? highlight : undefined, anchor ?? '') const inner = (

{title}

{badge && ( {badge} )}
{right}
{children}
) if (!anchor) return inner return (
{inner}
) }