diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index b124b3b..9cf8098 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -391,10 +391,6 @@ class MinuteSyncPrefs(BaseModel): minute_sync_days: int = 5 # 单段大小(交易日),None 表示不修改现有值。范围 [5, 30],默认 20。 minute_sync_segment_days: int | None = None - # 盘中分钟增量刷新 (Expert 专有)。None 表示不修改现有值。 - minute_refresh_enabled: bool | None = None - # 刷新间隔(秒),范围 [60, 300]。None 表示不修改现有值。 - minute_refresh_interval: int | None = None class DataProvidersIn(BaseModel): @@ -839,17 +835,11 @@ def update_minute_sync(req: MinuteSyncPrefs) -> dict: } if req.minute_sync_segment_days is not None: updates["minute_sync_segment_days"] = max(5, min(30, req.minute_sync_segment_days)) - if req.minute_refresh_enabled is not None: - updates["minute_refresh_enabled"] = req.minute_refresh_enabled - if req.minute_refresh_interval is not None: - updates["minute_refresh_interval"] = max(60, min(300, req.minute_refresh_interval)) preferences.save(updates) return { "minute_sync_enabled": req.minute_sync_enabled, "minute_sync_days": days, "minute_sync_segment_days": preferences.get_minute_sync_segment_days(), - "minute_refresh_enabled": preferences.get_minute_refresh_enabled(), - "minute_refresh_interval": preferences.get_minute_refresh_interval(), } @@ -1002,6 +992,9 @@ class RealtimeMonitorConfigIn(BaseModel): screener_auto_run: bool | None = None minute_intraday_refresh: bool | None = None minute_intraday_refresh_interval: int | None = None + # 盘中分钟增量落盘 (Expert 专有) — 交易时段常驻服务, 归实时监控配置 + minute_refresh_enabled: bool | None = None + minute_refresh_interval: int | None = None monitor_ext_fields: dict | None = None diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 184c576..906789d 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -940,6 +940,13 @@ def set_realtime_monitor_config(cfg: dict) -> dict: updates["minute_intraday_refresh_interval"] = max( _INTRADAY_REFRESH_INTERVAL_MIN, min(_INTRADAY_REFRESH_INTERVAL_MAX, int(cfg["minute_intraday_refresh_interval"]))) + if "minute_refresh_enabled" in cfg: + updates["minute_refresh_enabled"] = bool(cfg["minute_refresh_enabled"]) + if "minute_refresh_interval" in cfg: + # clamp 到 [60, 300] (下限保证 60s 窗口至多一个全市场脉冲), 与 getter 一致 + updates["minute_refresh_interval"] = max( + _MINUTE_REFRESH_INTERVAL_MIN, + min(_MINUTE_REFRESH_INTERVAL_MAX, int(cfg["minute_refresh_interval"]))) if "monitor_ext_fields" in cfg: raw = cfg["monitor_ext_fields"] or {} updates["monitor_ext_fields"] = { @@ -961,6 +968,8 @@ def get_realtime_monitor_config() -> dict: "screener_auto_run": get_screener_auto_run(), "minute_intraday_refresh": get_minute_intraday_refresh(), "minute_intraday_refresh_interval": get_minute_intraday_refresh_interval(), + "minute_refresh_enabled": get_minute_refresh_enabled(), + "minute_refresh_interval": get_minute_refresh_interval(), "monitor_ext_fields": get_monitor_ext_fields(), } diff --git a/backend/tests/test_minute_refresh.py b/backend/tests/test_minute_refresh.py index 21e5cd1..8e87d5d 100644 --- a/backend/tests/test_minute_refresh.py +++ b/backend/tests/test_minute_refresh.py @@ -183,6 +183,20 @@ def test_refresh_preferences_defaults_and_clamp(tmp_path, monkeypatch): preferences.save({"minute_refresh_interval": 90}) assert preferences.get_minute_refresh_interval() == 90 +def test_realtime_monitor_config_owns_refresh_keys(tmp_path, monkeypatch): + """盘中增量配置归属实时监控端点 (set_realtime_monitor_config), 并 clamp 到 [60,300]。""" + _isolated_prefs(tmp_path, monkeypatch) + saved = preferences.set_realtime_monitor_config({ + "minute_refresh_enabled": True, + "minute_refresh_interval": 10, # 越界 → clamp 到下限 + }) + assert saved["minute_refresh_enabled"] is True + assert saved["minute_refresh_interval"] == 60 + saved = preferences.set_realtime_monitor_config({"minute_refresh_interval": 400}) + assert saved["minute_refresh_interval"] == 300 + saved = preferences.set_realtime_monitor_config({"minute_refresh_interval": 120}) + assert saved["minute_refresh_interval"] == 120 + def test_status_endpoint_without_service(): from fastapi import FastAPI diff --git a/frontend/src/components/data/MinuteSyncConfig.tsx b/frontend/src/components/data/MinuteSyncConfig.tsx index 86ce910..6a79a8a 100644 --- a/frontend/src/components/data/MinuteSyncConfig.tsx +++ b/frontend/src/components/data/MinuteSyncConfig.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Loader2, Trash2, Download, Calendar, Zap } from 'lucide-react' +import { Loader2, Trash2, Download, Calendar } from 'lucide-react' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { MissingCapChip } from '@/lib/capability-labels' @@ -12,59 +12,20 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string; queryFn: api.preferences, }) const update = useMutation({ - mutationFn: ({ enabled, days, segmentDays, refresh }: { - enabled: boolean; days: number; segmentDays?: number - refresh?: { enabled?: boolean; interval?: number } - }) => - api.updateMinuteSync(enabled, days, segmentDays, refresh), - onSuccess: () => { - qc.invalidateQueries({ queryKey: QK.preferences }) - qc.invalidateQueries({ queryKey: ['minute-refresh-status'] }) - }, - }) - - // 盘中增量刷新状态 (轮询 15s, 仅弹窗挂载期间) - const refreshStatus = useQuery({ - queryKey: ['minute-refresh-status'], - queryFn: api.minuteRefreshStatus, - refetchInterval: 15000, + mutationFn: ({ enabled, days, segmentDays }: { enabled: boolean; days: number; segmentDays?: number }) => + api.updateMinuteSync(enabled, days, segmentDays), + onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }), }) const hasMinuteCap = !!caps?.capabilities?.['kline.minute.batch'] const enabled = prefs.data?.minute_sync_enabled ?? false const days = prefs.data?.minute_sync_days ?? 5 const segmentDays = prefs.data?.minute_sync_segment_days ?? 20 - const refreshEnabled = prefs.data?.minute_refresh_enabled ?? false - const refreshInterval = prefs.data?.minute_refresh_interval ?? 60 const [localDays, setLocalDays] = useState(days) const [localSegment, setLocalSegment] = useState(segmentDays) - const [localRefreshInterval, setLocalRefreshInterval] = useState(refreshInterval) useEffect(() => { setLocalDays(days) }, [days]) useEffect(() => { setLocalSegment(segmentDays) }, [segmentDays]) - useEffect(() => { setLocalRefreshInterval(refreshInterval) }, [refreshInterval]) - - // 盘中增量 = intraday.batch 独立能力 (Expert 专有), 与盘后同步的 minute.batch 分属不同限流池 - const hasIntradayBatchCap = !!caps?.capabilities?.['intraday.batch'] - const rs = refreshStatus.data - const refreshGateText = rs?.custom_provider_active - ? '已配置自定义分钟源, 盘中增量由插件自管' - : rs && rs.available && !rs.capability_ok - ? '需要日内分时批量能力 (Expert)' - : !rs?.in_trading_hours ? '非连续竞价时段, 暂停中' - : rs?.last_error ? `最近错误: ${rs.last_error}` - : null - - const handleRefreshToggle = () => { - if (!hasIntradayBatchCap) return - update.mutate({ enabled, days: localDays, refresh: { enabled: !refreshEnabled } }) - } - - const setRefreshInterval = (v: number) => { - const clamped = Math.max(60, Math.min(300, Math.round(v / 30) * 30)) - setLocalRefreshInterval(clamped) - update.mutate({ enabled, days: localDays, refresh: { interval: clamped } }) - } useEffect(() => { setLocalDays(days) }, [days]) useEffect(() => { setLocalSegment(segmentDays) }, [segmentDays]) @@ -189,67 +150,6 @@ export function MinuteSyncConfig({ caps, onJobStart }: { caps: { label: string; - {/* 区块 A2: 盘中增量刷新 (Expert 专有, intraday.batch 独立限流池) */} -
-
-
- -
- - - 盘中增量刷新{refreshEnabled ? '已开启' : '已关闭'} - -
-
-
-
- -
- {Math.round(localRefreshInterval / 60) >= 1 && localRefreshInterval % 60 === 0 ? `${localRefreshInterval / 60}m` : `${localRefreshInterval}s`} -
- -
- {!hasIntradayBatchCap && } -
-
-
- 交易时段内用日内分时批量 (独立配额) 每 {Math.round(localRefreshInterval / 60) >= 1 && localRefreshInterval % 60 === 0 ? `${localRefreshInterval / 60} 分钟` : `${localRefreshInterval} 秒`} 全市场脉冲落盘一次, - 分钟策略读到最新K线; 不占用盘后分钟同步的限流配额。 -
- {/* 运行状态一行: 门控原因 / 下一轮 / 最近一轮 */} - {rs?.available && ( -
- - ● {rs.running ? '服务运行中' : '服务未运行'} - - {refreshGateText && {refreshGateText}} - {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` : ''} - )} -
- )} -
- {/* 区块 B: 手动获取 (一次性操作, 独立于上方自动同步开关) */}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index de54b86..d38429b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1648,15 +1648,13 @@ export const api = { }), }, ), - updateMinuteSync: (enabled: boolean, days: number, segmentDays?: number, refresh?: { enabled?: boolean; interval?: number }) => + updateMinuteSync: (enabled: boolean, days: number, segmentDays?: number) => request('/api/settings/preferences/minute-sync', { method: 'PUT', body: JSON.stringify({ minute_sync_enabled: enabled, minute_sync_days: days, ...(segmentDays != null ? { minute_sync_segment_days: segmentDays } : {}), - ...(refresh?.enabled != null ? { minute_refresh_enabled: refresh.enabled } : {}), - ...(refresh?.interval != null ? { minute_refresh_interval: refresh.interval } : {}), }), }), diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index 94607b1..f515151 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -54,9 +54,21 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const intradayInterval = prefs?.minute_intraday_refresh_interval ?? 6 // 滑块本地草稿: 拖动时即时反馈, 停顿 2s 后落库 (与行情轮询滑块一致) const [intradayIntervalDraft, setIntradayIntervalDraft] = useState(intradayInterval) + // 盘中分钟增量 (Expert 专有): 间隔 (秒), 与后端 [60,300] clamp 对齐; 默认 60 + const minuteRefreshInterval = prefs?.minute_refresh_interval ?? 60 + 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.batch 独立能力 (Expert 专有), 与盘后同步的 minute.batch 分属不同限流池 + const hasIntradayBatchCap = !!caps?.capabilities?.['intraday.batch'] + const rs = refreshStatus.data // 新建监控规则时默认勾选的推送渠道 (全局默认值数组, 单条规则可独立修改) const webhookDefaultChannels = prefs?.webhook_default_channels ?? [] const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol) @@ -262,6 +274,20 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = 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]) + // highlight=depth-fix 时闪烁高亮连板梯队修正卡片 const [flash, setFlash] = useState(false) const flashedRef = useRef(false) @@ -282,6 +308,57 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = {/* ========== 左列 ========== */}
{/* 行情状态 — 开关 + 间隔 */} + {/* 盘中分钟增量落盘 (Expert 专有): 交易时段常驻服务, intraday.batch 独立配额 */} + + save({ minute_refresh_enabled: v })} + disabled={!hasIntradayBatchCap || !!rs?.custom_provider_active} + /> +
+
+
+
刷新间隔
+
+ 交易时段内全市场脉冲落盘一轮的间隔; 下限 60s 保证不超 intraday.batch 配额 +
+
+ + {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秒后保存' : '60s — 300s'} + +
+ {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}` : ''} +
+ )} +
+
+