mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
像其他能力一样可路由: 声明 full_minute 数据集并在设置页路由, 即接入 盘中全市场分钟落盘服务 (与 TickFlow Expert 同一能力键 intraday.universe)。 - policy: _DATASET_CAP_MAP 增补 full_minute → INTRADAY_UNIVERSE, 自定义源 声明且被路由时自动补授能力, 服务门控对两类源统一口径 - capabilities 注册表: full_minute 从不可路由 (field=None) 改为 full_minute_data_provider 偏好路由; preferences/settings API 收发新字段 - 插件契约: get_intraday_batch (修复轮, 未实现回退 get_minute 当日窗口) + get_intraday_latest (稳态增量, 可选; 未实现降级仅修复轮, 节奏下限 60s) - YAML 声明式源: full_minute 数据集与 minute 同形, loader 白名单放行, 源编辑器 UI 可配置; 仅修复轮语义 - kline_sync 边界: fetch_intraday_custom_batch / _fetch_intraday_custom_latest, 帧过北京墙钟守卫, 与 TickFlow 边界函数同纪律 - minute_refresh: 删「自定义分钟源让位」逻辑, 改按路由取数; status 增 provider / provider_effective / repair_only - 前端: 监控页卡片按能力驱动, 数据源页路由矩阵, api.ts 类型 - 测试: 门控/路由/降级/增广新增用例, 后端 1332 全过 - 文档: plugin-development / custom-data-source / configuration / features / CONTRIBUTING 契约细化 (AI 可照文档接入)
818 lines
38 KiB
TypeScript
818 lines
38 KiB
TypeScript
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=<anchor> 时该卡片滚动到视口中央并闪烁高亮。
|
|
// 其他页面用 /settings?tab=monitoring&highlight=<anchor> 精确引导用户到某张卡片。
|
|
const HighlightContext = createContext('')
|
|
|
|
// 页面 → 显示名
|
|
const PAGE_LABELS: Record<string, string> = {
|
|
'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<string, unknown>) => {
|
|
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 (
|
|
<HighlightContext.Provider value={highlight ?? ''}>
|
|
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-6 max-w-5xl">
|
|
{/* ========== 左列 ========== */}
|
|
<div className="space-y-6">
|
|
{/* 行情状态 — 开关 + 间隔 */}
|
|
<Card icon={Activity} title="行情轮询" anchor="quotes">
|
|
<ToggleRow
|
|
label="实时行情"
|
|
desc={
|
|
isPaused ? '数据同步运行中,已临时暂停'
|
|
: isRunning && isTrading ? '运行中'
|
|
: isRunning ? '运行中 (非交易时段)'
|
|
: '已关闭'
|
|
}
|
|
checked={realtimeEnabled}
|
|
onChange={handleToggleQuote}
|
|
disabled={isPaused}
|
|
/>
|
|
|
|
<div className="mt-3 pt-3 border-t border-border">
|
|
<div className="flex items-center justify-between gap-4 py-1">
|
|
<div className="min-w-0">
|
|
<div className="text-sm text-foreground">轮询间隔</div>
|
|
<div className="text-[11px] text-muted">
|
|
每轮拉取全市场行情的时间间隔
|
|
</div>
|
|
</div>
|
|
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
|
|
{intervalDraft < 1 ? intervalDraft.toFixed(1) : intervalDraft.toFixed(0)}s
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<input
|
|
type="range"
|
|
min={minInterval}
|
|
max={maxInterval}
|
|
step={minInterval < 1 ? 0.1 : minInterval < 3 ? 0.5 : 1}
|
|
value={intervalDraft}
|
|
onChange={(e) => setIntervalDraft(parseFloat(e.target.value))}
|
|
className="flex-1 h-1 accent-accent cursor-pointer"
|
|
/>
|
|
<span className="text-[10px] text-muted shrink-0">
|
|
{intervalDraft !== interval ? '2秒后保存' : `${minInterval}s — ${maxInterval}s`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card icon={Wifi} title="页面实时刷新">
|
|
<p className="text-xs text-secondary mb-4">
|
|
选择哪些页面跟随 SSE 实时刷新数据。关闭的页面不会被推送,
|
|
但行情轮询和策略监控不受影响。
|
|
</p>
|
|
<div className="space-y-2">
|
|
{Object.entries(PAGE_LABELS).map(([key, label]) => (
|
|
<ToggleRow
|
|
key={key}
|
|
label={label}
|
|
desc={`SSE 推送时刷新 ${label} 数据`}
|
|
checked={refreshPages[key] !== false}
|
|
onChange={(v) => save({ sse_refresh_pages: { ...refreshPages, [key]: v } })}
|
|
/>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* 自选列表分时图实时刷新 (默认关闭, 开启后盘中按设定间隔轮询刷新分时数据) */}
|
|
<Card icon={Activity} title="分时图刷新" anchor="intraday-refresh">
|
|
<ToggleRow
|
|
label="自选/策略分时图实时刷新"
|
|
desc={`开启后自选与策略列表的分时图盘中每 ${intradayInterval} 秒自动刷新(依赖分钟K批量数据 + 实时行情运行)。关闭时仅打开页面时拉取一次, 可点表头刷新按钮手动更新。`}
|
|
checked={prefs?.minute_intraday_refresh ?? false}
|
|
onChange={(v) => save({ minute_intraday_refresh: v })}
|
|
/>
|
|
<div className="mt-3 pt-3 border-t border-border">
|
|
<div className="flex items-center justify-between gap-4 py-1">
|
|
<div className="min-w-0">
|
|
<div className="text-sm text-foreground">刷新间隔</div>
|
|
<div className="text-[11px] text-muted">
|
|
间隔越短更新越及时, 但越耗数据源配额 (rpm)
|
|
</div>
|
|
</div>
|
|
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
|
|
{intradayIntervalDraft}s
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<input
|
|
type="range"
|
|
min={3}
|
|
max={60}
|
|
step={1}
|
|
value={intradayIntervalDraft}
|
|
onChange={(e) => setIntradayIntervalDraft(parseInt(e.target.value, 10))}
|
|
className="flex-1 h-1 accent-accent cursor-pointer"
|
|
/>
|
|
<span className="text-[10px] text-muted shrink-0">
|
|
{intradayIntervalDraft !== intradayInterval ? '2秒后保存' : '3s — 60s'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
</div>
|
|
|
|
{/* ========== 右列 ========== */}
|
|
<div className="space-y-6">
|
|
{/* 全量分钟: 盘中全市场分钟落盘, 按能力路由 (TickFlow Expert 或声明 full_minute 的插件/自定义源) */}
|
|
<Card icon={Zap} title="全量分钟" anchor="minute-refresh">
|
|
<ToggleRow
|
|
label="全量分钟落盘"
|
|
desc={
|
|
!hasFullMinuteCap ? '需要全量分钟能力 (TickFlow Expert 或声明该能力的自定义源)'
|
|
: rs?.repair_only ? `服务运行中 · ${rs?.provider ?? '自定义源'} 无廉价增量端点, 按 ≥60s 全天批量节奏`
|
|
: rs?.running ? (rs?.in_trading_hours ? '服务运行中' : '运行中 · 非连续竞价时段暂停')
|
|
: '已关闭'
|
|
}
|
|
checked={prefs?.minute_refresh_enabled ?? false}
|
|
onChange={(v) => save({ minute_refresh_enabled: v })}
|
|
disabled={!hasFullMinuteCap}
|
|
/>
|
|
<div className="mt-3 pt-3 border-t border-border">
|
|
<div className="flex items-center justify-between gap-4 py-1">
|
|
<div className="min-w-0">
|
|
<div className="text-sm text-foreground">刷新间隔</div>
|
|
<div className="text-[11px] text-muted">
|
|
交易时段内全市场分钟K增量落盘的间隔; 稳态单请求增量, 冷启动/断档自动全天回补
|
|
</div>
|
|
</div>
|
|
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
|
|
{minuteRefreshIntervalDraft >= 60 && minuteRefreshIntervalDraft % 60 === 0 ? `${minuteRefreshIntervalDraft / 60}m` : `${minuteRefreshIntervalDraft}s`}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<input
|
|
type="range"
|
|
min={3}
|
|
max={120}
|
|
step={3}
|
|
value={minuteRefreshIntervalDraft}
|
|
disabled={!hasFullMinuteCap}
|
|
onChange={(e) => setMinuteRefreshIntervalDraft(parseInt(e.target.value, 10))}
|
|
className="flex-1 h-1 accent-accent cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
|
/>
|
|
<span className="text-[10px] text-muted shrink-0">
|
|
{minuteRefreshIntervalDraft !== minuteRefreshInterval ? '2秒后保存' : '3s — 120s'}
|
|
</span>
|
|
</div>
|
|
{rs?.available && rs.rounds != null && rs.rounds > 0 && (
|
|
<div className="mt-2 text-[10px] text-muted">
|
|
已 {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}` : ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* 连板梯队降级修正 */}
|
|
<Card
|
|
icon={Flame}
|
|
title="连板梯队降级修正"
|
|
anchor="depth-fix"
|
|
badge={!hasDepth ? '五档盘口不可用' : undefined}
|
|
right={hasDepth ? (
|
|
<button
|
|
onClick={() => 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"
|
|
>
|
|
<Zap className="h-3 w-3" />
|
|
{runFix.isPending ? '修正中…' : '立即修正'}
|
|
</button>
|
|
) : undefined}
|
|
>
|
|
{hasDepth ? (
|
|
<>
|
|
<p className="text-xs text-secondary mb-4">
|
|
通过五档盘口实时修正真假涨停/跌停。真封板显示封单量,假涨停(收盘价=涨停价但卖一有量)归入炸板。
|
|
盘中按设定间隔轮询,收盘后自动定版。
|
|
</p>
|
|
<ToggleRow
|
|
label="启用真假板修正"
|
|
desc="开启后盘中自动拉取五档盘口修正真假板"
|
|
checked={limitLadderMonitor}
|
|
onChange={toggleLimitLadderMonitor}
|
|
/>
|
|
<div className="mt-4 pt-3 border-t border-border">
|
|
<div className="text-[10px] uppercase tracking-widest text-muted mb-3">
|
|
五档盘口配置
|
|
</div>
|
|
<DepthConfigContent disabled={!limitLadderMonitor} />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<DepthConfigContent disabled />
|
|
)}
|
|
</Card>
|
|
|
|
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。
|
|
飞书 / 企业微信。
|
|
每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}
|
|
<Card icon={Webhook} title="推送通知" anchor="webhooks">
|
|
<p className="text-xs text-secondary mb-3">
|
|
监控规则命中后,可把告警推送到外部。勾选渠道作为<b className="text-foreground/80">新建规则的默认推送</b>,
|
|
单条规则仍可在编辑页独立修改。
|
|
</p>
|
|
|
|
{/* 渠道列表 — 每行一个渠道, 勾选默认 + 点行展开地址配置 */}
|
|
<div className="space-y-2">
|
|
{/* 飞书 (可用): 勾选默认 + 展开地址配置 */}
|
|
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
|
|
<div
|
|
onClick={() => setChannelOpen(o => !o)}
|
|
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={webhookDefaultChannels.includes('feishu')}
|
|
onChange={e => { e.stopPropagation(); toggleDefaultChannel('feishu', e.target.checked) }}
|
|
onClick={e => e.stopPropagation()}
|
|
title="作为新建规则的默认推送渠道"
|
|
className="h-3 w-3 accent-accent cursor-pointer"
|
|
/>
|
|
<span className="text-[11px] font-medium text-foreground">飞书</span>
|
|
<span className="text-[9px] text-muted">群推送 Webhook</span>
|
|
{webhookDefaultChannels.includes('feishu') && (
|
|
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent">默认</span>
|
|
)}
|
|
<span className={`ml-auto text-[9px] ${feishuWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
|
|
{feishuWebhookUrl ? '已配置' : '未配置'}
|
|
</span>
|
|
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${channelOpen ? 'rotate-180' : ''}`} />
|
|
</div>
|
|
|
|
{/* 飞书地址配置 — 行内展开 */}
|
|
{channelOpen && (
|
|
<div className="border-t border-border/60 bg-base/30 p-3">
|
|
<label className="block space-y-1.5">
|
|
<span className="text-[11px] text-muted">Webhook 地址</span>
|
|
<input
|
|
value={feishuDraft}
|
|
onChange={e => setFeishuDraft(e.target.value)}
|
|
placeholder={FEISHU_PREFIX + 'xxxxxxxx'}
|
|
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
|
/>
|
|
</label>
|
|
|
|
<label className="block mt-2 space-y-1.5">
|
|
<span className="text-[11px] text-muted">签名密钥 (可选 · 启用签名校验时填)</span>
|
|
<input
|
|
type="password"
|
|
value={feishuSecretDraft}
|
|
onChange={e => setFeishuSecretDraft(e.target.value)}
|
|
placeholder="机器人未启用签名校验则留空"
|
|
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
|
/>
|
|
</label>
|
|
|
|
{feishuError && (
|
|
<div className="mt-2 text-[11px] text-danger">{feishuError}</div>
|
|
)}
|
|
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<button
|
|
onClick={submitFeishu}
|
|
disabled={saveFeishuWebhook.isPending || (feishuDraft.trim() === feishuWebhookUrl && feishuSecretDraft.trim() === feishuWebhookSecret)}
|
|
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
|
|
>
|
|
{saveFeishuWebhook.isPending ? '保存中…' : '保存'}
|
|
</button>
|
|
{feishuWebhookUrl && (
|
|
<span className="text-[10px] text-emerald-500">● 已配置</span>
|
|
)}
|
|
</div>
|
|
|
|
<details className="mt-3 text-[10px] text-muted">
|
|
<summary className="cursor-pointer hover:text-secondary">如何获取飞书 Webhook 地址?</summary>
|
|
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
|
|
<li>打开飞书,进入目标群聊 → 群设置 → <b>群推送 Webhook</b></li>
|
|
<li>点击「添加机器人」→ 选择「<b>自定义机器人</b>」</li>
|
|
<li>填写机器人名称后添加,复制生成的 Webhook 地址</li>
|
|
<li>安全设置若启用了「<b>签名校验</b>」,把密钥一并复制填到「签名密钥」框</li>
|
|
<li>粘贴到上方输入框并保存</li>
|
|
</ol>
|
|
<p className="mt-1.5 pl-4 text-muted/70">
|
|
📖 官方文档:
|
|
<a href="https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot?lang=zh-CN" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
|
|
自定义机器人使用指南 ↗
|
|
</a>
|
|
</p>
|
|
</details>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 企业微信群推送 Webhook (可用): 与飞书并列, 勾选默认 + 展开地址配置 */}
|
|
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
|
|
<div
|
|
onClick={() => setWecomOpen(o => !o)}
|
|
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={webhookDefaultChannels.includes('wecom')}
|
|
onChange={e => { e.stopPropagation(); toggleDefaultChannel('wecom', e.target.checked) }}
|
|
onClick={e => e.stopPropagation()}
|
|
title="作为新建规则的默认推送渠道"
|
|
className="h-3 w-3 accent-accent cursor-pointer"
|
|
/>
|
|
<span className="text-[11px] font-medium text-foreground">企业微信</span>
|
|
<span className="text-[9px] text-muted">群推送 Webhook</span>
|
|
{webhookDefaultChannels.includes('wecom') && (
|
|
<span className="rounded bg-accent/15 px-1 py-px text-[9px] text-accent">默认</span>
|
|
)}
|
|
<span className={`ml-auto text-[9px] ${wecomWebhookUrl ? 'text-emerald-500' : 'text-warning'}`}>
|
|
{wecomWebhookUrl ? '已配置' : '未配置'}
|
|
</span>
|
|
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${wecomOpen ? 'rotate-180' : ''}`} />
|
|
</div>
|
|
|
|
{wecomOpen && (
|
|
<div className="border-t border-border/60 bg-base/30 p-3">
|
|
<label className="block space-y-1.5">
|
|
<span className="text-[11px] text-muted">Webhook 地址 或 Key</span>
|
|
<input
|
|
value={wecomDraft}
|
|
onChange={e => setWecomDraft(e.target.value)}
|
|
placeholder={WECOM_PREFIX + '?key=xxxxxxxx' + ' 或直接填 key'}
|
|
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
|
/>
|
|
</label>
|
|
|
|
{wecomError && (
|
|
<div className="mt-2 text-[11px] text-danger">{wecomError}</div>
|
|
)}
|
|
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<button
|
|
onClick={submitWecom}
|
|
disabled={saveWecomWebhook.isPending || wecomDraft.trim() === wecomWebhookUrl}
|
|
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
|
|
>
|
|
{saveWecomWebhook.isPending ? '保存中…' : '保存'}
|
|
</button>
|
|
{wecomWebhookUrl && (
|
|
<span className="text-[10px] text-emerald-500">● 已配置</span>
|
|
)}
|
|
</div>
|
|
|
|
<details className="mt-3 text-[10px] text-muted">
|
|
<summary className="cursor-pointer hover:text-secondary">如何获取企业微信 Webhook 地址?</summary>
|
|
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
|
|
<li>打开企业微信,进入目标群聊 → 右上角「...」→ <b>群推送 Webhook</b></li>
|
|
<li>点击「添加」→ 选择「<b>消息推送</b>」→ 填写名字</li>
|
|
<li>复制生成的 <b>Webhook 地址</b>(含 key 参数),粘贴到上方输入框</li>
|
|
<li>也可只复制 key 参数部分(= 后面的内容)填入</li>
|
|
<li>企业微信群的消息可同步到绑定的个人微信,实现"微信推送"</li>
|
|
</ol>
|
|
<p className="mt-1.5 pl-4 text-muted/70">
|
|
📖 官方文档:
|
|
<a href="https://developer.work.weixin.qq.com/document/path/91770" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
|
|
群推送 Webhook 使用指南 ↗
|
|
</a>
|
|
</p>
|
|
</details>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 企业微信智能机器人 (BotID + Secret): 长连接通道, 与群推送 Webhook 并列 */}
|
|
<div className="rounded-btn border border-border/60 bg-base/40 overflow-hidden">
|
|
<div
|
|
onClick={() => setBotOpen(o => !o)}
|
|
className="flex items-center gap-2 px-2.5 py-2 cursor-pointer transition-colors hover:bg-base/60"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={wecomBotEnabled}
|
|
onChange={e => { 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"
|
|
/>
|
|
<span className="text-[11px] font-medium text-foreground">企业微信</span>
|
|
<span className="text-[9px] text-muted">智能机器人</span>
|
|
<span className={`ml-auto text-[9px] ${wecomBotId ? (botStatus?.connected ? 'text-emerald-500' : 'text-warning') : 'text-muted'}`}>
|
|
{wecomBotId ? (botStatus?.connected ? '已连接' : (wecomBotEnabled ? '连接中' : '已配置')) : '未配置'}
|
|
</span>
|
|
<ChevronDown className={`h-3 w-3 text-muted transition-transform ${botOpen ? 'rotate-180' : ''}`} />
|
|
</div>
|
|
|
|
{botOpen && (
|
|
<div className="border-t border-border/60 bg-base/30 p-3">
|
|
<p className="mb-2.5 text-[10px] text-muted leading-relaxed">
|
|
勾选卡片左侧开关可启用长连接保活(开启后后端持续保持与企业微信的
|
|
WebSocket 连接)。保存凭证后需勾选才会连接, 取消勾选则立即断开。
|
|
</p>
|
|
<label className="block space-y-1.5">
|
|
<span className="text-[11px] text-muted">BotID</span>
|
|
<input
|
|
value={botIdDraft}
|
|
onChange={e => setBotIdDraft(e.target.value)}
|
|
placeholder="智能机器人的唯一标识"
|
|
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
|
/>
|
|
</label>
|
|
|
|
<label className="block mt-2 space-y-1.5">
|
|
<span className="text-[11px] text-muted">Secret (长连接专用密钥)</span>
|
|
<input
|
|
type="password"
|
|
value={botSecretDraft}
|
|
onChange={e => setBotSecretDraft(e.target.value)}
|
|
placeholder="开启长连接 API 模式后获取的密钥"
|
|
className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground focus:outline-none focus:border-accent/50"
|
|
/>
|
|
</label>
|
|
|
|
{botError && (
|
|
<div className="mt-2 text-[11px] text-danger">{botError}</div>
|
|
)}
|
|
|
|
{botStatus?.last_error && !botError && (
|
|
<div className="mt-2 text-[11px] text-warning">连接异常: {botStatus.last_error}</div>
|
|
)}
|
|
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<button
|
|
onClick={submitBot}
|
|
disabled={saveWecomBot.isPending || (botIdDraft.trim() === wecomBotId && botSecretDraft.trim() === wecomBotSecret)}
|
|
className="px-3 py-1.5 rounded-btn bg-accent text-base text-xs font-medium disabled:opacity-50 cursor-pointer hover:bg-accent/90 transition-colors"
|
|
>
|
|
{saveWecomBot.isPending ? '保存中…' : '保存并连接'}
|
|
</button>
|
|
{wecomBotId && (
|
|
<span className="text-[10px] text-emerald-500">● 已配置</span>
|
|
)}
|
|
</div>
|
|
|
|
<details className="mt-3 text-[10px] text-muted">
|
|
<summary className="cursor-pointer hover:text-secondary">如何获取 BotID 和 Secret?</summary>
|
|
<ol className="mt-1.5 space-y-1 pl-4 list-decimal leading-relaxed">
|
|
<li>登录<b>企业微信管理后台</b> → 应用管理 → <b>智能机器人</b> → 创建机器人</li>
|
|
<li>填写名称、头像后进入机器人配置页</li>
|
|
<li>开启「<b>API 模式</b>」→ 选择「<b>长连接</b>」方式(另一项"回调URL"需公网IP)</li>
|
|
<li>页面显示 <b>BotID</b> 和 <b>Secret</b>, 复制填到上方输入框</li>
|
|
</ol>
|
|
<p className="mt-1.5 pl-4 text-muted/70">
|
|
💡 智能机器人支持 @交互和流式回复, 与群推送 Webhook(单向推送)互补。
|
|
保存后后端会自动建立 WebSocket 长连接保活。
|
|
</p>
|
|
<p className="mt-1.5 pl-4 text-muted/70">
|
|
📖 官方文档:
|
|
<a href="https://developer.work.weixin.qq.com/document/path/101463" target="_blank" rel="noreferrer" className="text-accent hover:text-accent/80">
|
|
智能机器人长连接 ↗
|
|
</a>
|
|
</p>
|
|
</details>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</HighlightContext.Provider>
|
|
)
|
|
}
|
|
|
|
|
|
// ===== 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 (
|
|
<div className="flex items-center justify-between gap-4 py-2">
|
|
<div className="min-w-0 flex items-start gap-2">
|
|
{Icon && <Icon className="h-3.5 w-3.5 text-secondary shrink-0 mt-0.5" />}
|
|
<div className="min-w-0">
|
|
<div className="text-sm text-foreground">{label}</div>
|
|
<div className="text-[11px] text-muted truncate">{desc}</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => !disabled && onChange(!checked)}
|
|
disabled={disabled}
|
|
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${
|
|
checked ? 'bg-accent' : 'bg-elevated'
|
|
} ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
|
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|
|
// ===== 通用卡片 =====
|
|
|
|
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 = (
|
|
<section className="rounded-card border border-border bg-surface p-5">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<Icon className="h-4 w-4 text-secondary" />
|
|
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
|
{badge && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded bg-elevated text-muted">
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{right}
|
|
</div>
|
|
{children}
|
|
</section>
|
|
)
|
|
if (!anchor) return inner
|
|
return (
|
|
<div ref={ref} id={anchor} className={cardFlashCls(flash)}>
|
|
{inner}
|
|
</div>
|
|
)
|
|
}
|