删除实时行情、盘口深度和监控规则功能

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-04 17:32:39 +08:00
parent 0474e5fb46
commit 9904854cc1
47 changed files with 107 additions and 6059 deletions
+1 -19
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, Info, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Database, Flame, Gauge, LineChart, Loader2, Play, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -550,7 +550,6 @@ export function Dashboard() {
}).catch(() => { /* 查询失败不阻塞, 用户仍可手动点击获取 */ })
}, [hasNoData, fetchJobId])
// 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感
const handleRefresh = () => {
setManualFetching(true)
overview.refetch().finally(() => setManualFetching(false))
@@ -582,10 +581,6 @@ export function Dashboard() {
const strongDown = data.breadth.strong_down ?? 0
const latestDate = dataStatus.data?.enriched?.latest_date ?? null
const currentDate = selectedDate ?? data.as_of ?? ''
const quoteRunning = (!selectedDate || selectedDate === latestDate) && data.quote_status?.running
// 实时模式: none / watchlist / full_market。
// watchlist (Free 档) 仅自选 ≤5 只实时, 看板呈现的大盘数据实为盘后快照, 需提示避免误读。
const quoteMode = data.quote_status?.mode as ('none' | 'watchlist' | 'full_market') | undefined
return (
<div className="min-h-full bg-base p-3">
@@ -642,7 +637,6 @@ export function Dashboard() {
<span className="font-mono text-secondary"></span>
)}
<span className="flex items-center gap-1"><Timer className="h-3 w-3" />{quoteAge(data.quote_status?.quote_age_ms)}</span>
<span className={quoteRunning ? 'text-accent' : 'text-warning'}>{quoteRunning ? '实时' : '非实时'}</span>
<button
onClick={handleRefresh}
disabled={manualFetching}
@@ -653,18 +647,6 @@ export function Dashboard() {
</div>
</div>
{/* Free 档提示: 大盘看板为盘后数据, 仅自选股实时。避免用户误读为全市场实时。 */}
{quoteMode === 'watchlist' && (
<div className="mb-3 flex items-start gap-2 rounded-card border border-amber-500/30 bg-amber-500/8 px-3 py-2 text-[11px] leading-relaxed">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<div className="min-w-0 flex-1 text-secondary">
,<strong className="text-foreground"></strong>(),;
({data.quote_status?.watchlist_symbol_count ?? 0} )
<span className="ml-1 text-accent"> Starter+</span>
</div>
</div>
)}
<div className="mb-3 grid grid-cols-4 gap-2">
{data.indices.map(item => <IndexTicker key={item.symbol} item={item} />)}
</div>
+2 -50
View File
@@ -23,11 +23,8 @@ import {
useCapabilities,
useSettings,
usePreferences,
useQuoteStatus,
useQuoteInterval,
useDataStatus,
} from '@/lib/useSharedQueries'
import { useToggleRealtimeQuotes, useUpdateQuoteInterval } from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { formatScheduleDatePart, formatScheduleTimePart, isToday } from '@/lib/format'
@@ -43,7 +40,6 @@ import { EnrichedRebuildPanel } from '@/components/data/EnrichedRebuildPanel'
import { MinuteSyncConfig } from '@/components/data/MinuteSyncConfig'
import { PipelineScopeConfig } from '@/components/data/PipelineScopeConfig'
import { PageSettingsModal, getCardVisibility, getCardOrder, type CardKey } from '@/components/data/PageSettingsModal'
import { QuoteConfigCard } from '@/components/data/QuoteConfigCard'
import { EnrichedSchemaModal } from '@/components/data/SchemaModal'
import { Skeleton } from '@/components/data/Skeleton'
import { ExtDataStatCard } from '@/components/ext-data/ExtDataStatCard'
@@ -141,7 +137,6 @@ export function Data() {
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.dataStatus })
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
qc.invalidateQueries({ queryKey: ['index-daily'] })
},
})
@@ -161,33 +156,6 @@ export function Data() {
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const [showIntervalEdit, setShowIntervalEdit] = useState(false)
const handleToggleIntervalEdit = useCallback((fromEvent?: boolean) => {
setShowIntervalEdit(v => {
const next = !v
if (!fromEvent) {
window.dispatchEvent(new CustomEvent('quote-interval-editor-toggle', { detail: { source: 'data' } }))
}
return next
})
}, [])
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent
if (ce.detail?.source !== 'data') {
setShowIntervalEdit(v => !v)
}
}
window.addEventListener('quote-interval-editor-toggle', handler)
return () => window.removeEventListener('quote-interval-editor-toggle', handler)
}, [])
const quoteInterval = useQuoteInterval()
const updateInterval = useUpdateQuoteInterval()
const realtimeEnabled = prefs.data?.realtime_quotes_enabled ?? false
const quoteStatus = useQuoteStatus()
const toggleQuote = useToggleRealtimeQuotes()
const hasAdjCap = !!caps.data?.capabilities?.['adj_factor']
const hasDailyBatchCap = !!caps.data?.capabilities?.['kline.daily.batch']
const hasMinuteCap = !!caps.data?.capabilities?.['kline.minute.batch']
@@ -579,24 +547,8 @@ export function Data() {
)}
</AnimatePresence>
{/* 实时行情 + 存储 + 调度 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<QuoteConfigCard
enabled={realtimeEnabled}
running={quoteStatus.data?.running ?? false}
isTrading={quoteStatus.data?.is_trading_hours ?? false}
lastFetchMs={quoteStatus.data?.last_fetch_ms ?? null}
intervalS={quoteInterval.data?.interval ?? quoteStatus.data?.interval_s ?? 10}
intervalMin={quoteInterval.data?.min_interval ?? 5}
intervalMax={quoteInterval.data?.max_interval ?? 60}
loading={quoteStatus.isLoading}
onToggle={(v) => toggleQuote.mutate(v)}
toggling={toggleQuote.isPending}
showIntervalEdit={showIntervalEdit}
onShowIntervalEdit={handleToggleIntervalEdit}
onIntervalChange={(v) => updateInterval.mutate(v)}
/>
{/* 自动调度 + 存储 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 自动调度 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center gap-2 mb-3">
+3 -41
View File
@@ -44,16 +44,6 @@ function toOHLC(rows: KlineRow[]): OHLC[] {
}))
}
function fmtPct(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return `${Number(v).toFixed(2)}%`
}
function fmtNum(v: number | null | undefined, digits = 2) {
if (v == null || Number.isNaN(Number(v))) return '--'
return Number(v).toFixed(digits)
}
const PINNED_INDEXES = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
@@ -112,12 +102,6 @@ export function Indices() {
setSearchParams({ symbol })
}
const quotes = useQuery({
queryKey: QK.indexQuotes,
queryFn: () => api.indexQuotes(),
placeholderData: (prev) => prev,
})
const daily = useQuery({
queryKey: QK.indexDaily(selectedSymbol, range.start, range.end),
queryFn: () => api.indexDaily(selectedSymbol, 180, range),
@@ -136,7 +120,6 @@ export function Indices() {
mutationFn: api.syncIndexInstruments,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
},
})
@@ -144,20 +127,10 @@ export function Indices() {
mutationFn: () => api.syncIndexDaily(365),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.indexList })
qc.invalidateQueries({ queryKey: QK.indexQuotes })
qc.invalidateQueries({ queryKey: ['index-daily'] })
},
})
const quoteBySymbol = useMemo(() => {
const m = new Map<string, any>()
for (const q of quotes.data?.rows ?? []) m.set(q.symbol, q)
return m
}, [quotes.data?.rows])
const selectedQuote = selectedSymbol ? quoteBySymbol.get(selectedSymbol) : null
const selectedQuoteValue = selectedQuote?.last_price ?? selectedQuote?.price ?? selectedQuote?.close
const selectedQuotePct = selectedQuote?.change_pct ?? selectedQuote?.pct
const chartRows = useMemo(() => toOHLC(daily.data?.rows ?? []), [daily.data?.rows])
const selectedInfo = [...topRows, ...listRows].find(r => r.symbol === selectedSymbol) || daily.data?.index_info
const minuteRows: MinuteKlineRow[] = minute.data?.rows ?? []
@@ -179,9 +152,6 @@ export function Indices() {
}
}, [chartRows, daily.data?.symbol, selectedDate, selectedSymbol])
const renderIndexItem = (item: IndexInstrument) => {
const q = quoteBySymbol.get(item.symbol)
const pct = q?.change_pct ?? q?.pct
const current = q?.last_price ?? q?.price ?? q?.close
const active = item.symbol === selectedSymbol
return (
<button
@@ -189,14 +159,8 @@ export function Indices() {
onClick={() => selectIndex(item.symbol)}
className={`w-full rounded-btn px-2 py-2 text-left transition-colors ${active ? 'bg-accent/15 text-foreground' : 'hover:bg-elevated text-secondary'}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium">{item.name || item.symbol}</span>
<span className={`text-[10px] font-mono ${Number(pct ?? 0) >= 0 ? 'text-bull' : 'text-bear'}`}>{fmtPct(pct)}</span>
</div>
<div className="mt-0.5 flex items-center justify-between text-[10px] font-mono text-muted">
<span>{item.symbol}</span>
<span>{fmtNum(current)}</span>
</div>
<div className="truncate text-xs font-medium">{item.name || item.symbol}</div>
<div className="mt-0.5 text-[10px] font-mono text-muted">{item.symbol}</div>
</button>
)
}
@@ -264,11 +228,9 @@ export function Indices() {
{selectedInfo?.name || selectedSymbol || '未选择指数'}
</h2>
{selectedSymbol && <span className="font-mono text-xs text-muted">{selectedSymbol}</span>}
{selectedSymbol && <span className="font-mono text-xs text-foreground">{fmtNum(selectedQuoteValue)}</span>}
{selectedSymbol && <span className={`font-mono text-xs ${Number(selectedQuotePct ?? 0) >= 0 ? 'text-bull' : 'text-bear'}`}>{fmtPct(selectedQuotePct)}</span>}
</div>
<div className="mt-1 text-xs text-muted">
{quotes.data?.count ?? 0} · K来源 {daily.data?.source ?? '--'}
K来源 {daily.data?.source ?? '--'}
</div>
</div>
<div className="flex items-center gap-2 text-xs">
+1 -1
View File
@@ -374,7 +374,7 @@ function MonitorMenu({ stock, direction, sealMode, monitorRule, anchorRect, hasD
// 推送外部开关默认值: 取偏好设置中的全局默认 (已有规则沿用其值)
const { data: prefs } = usePreferences()
const webhookDefault = prefs?.webhook_enabled_default ?? false
const webhookDefault: boolean = (prefs as any)?.webhook_enabled_default ?? false
// 单位倍率: 输入值 × 倍率 = 原始单位 (量=手, 额=元)
const VOL_UNITS = [
+1 -1
View File
@@ -103,7 +103,7 @@ export function Review() {
const [showSchedule, setShowSchedule] = useState(false)
const prefs = usePreferences()
const reviewSched = prefs.data?.review_schedule ?? { enabled: false, hour: 15, minute: 10 }
const feishuConfigured = !!(prefs.data?.feishu_webhook_url)
const feishuConfigured = !!((prefs.data as any)?.feishu_webhook_url)
// 推送渠道是独立的顶层偏好(多选), 与定时 / 实时行情无关, 常驻可单独设置
// []=不推送, ['feishu']=飞书(微信开发中, 仅占位)
const reviewPushChannels = prefs.data?.review_push_channels ?? []
-42
View File
@@ -16,7 +16,6 @@ import { MiniCandlestick } from '@/components/stock-table/MiniCandlestick'
import { boardTag, renderBuiltinDataCell } from '@/components/stock-table/primitives'
import { getSignals, signalCls, getSortValue, UNSORTABLE_KEYS } from '@/lib/stock-table'
import { resolveCandleConfig } from '@/lib/list-columns'
import { useQuoteStatus } from '@/lib/useSharedQueries'
import {
type ColumnConfig,
BUILTIN_COLUMNS,
@@ -296,27 +295,6 @@ function StockSearchBox({
)
}
// ===== 实时监控圆点 =====
// 自选页 symbol 列代码后的小圆点, 标识该标的正在被实时行情监控 (Free/低档按自选监控模式)。
// 视觉: 内圈实心点 + 外圈 animate-ping 扩散晕, 语义=「在线/活动」。
// 配色用 accent (电光蓝) 而非绿/红: 项目设计规范规定红绿仅用于价格/K线,
// UI 状态用 accent, 避免与 A 股涨跌色混淆。
// 全市场模式 (Starter+) 不显示 —— 全部都在监控, 标记无信息量。
function RealtimeDot({ title = '实时监控中' }: { title?: string }) {
return (
<span
title={title}
className="relative inline-flex h-2 w-2 shrink-0"
aria-label={title}
>
{/* 外圈: 扩散晕 (ping 动画) */}
<span className="absolute inline-flex h-full w-full rounded-full bg-accent/60 animate-ping motion-reduce:hidden" />
{/* 内圈: 实心点 + 微辉光 */}
<span className="relative inline-flex rounded-full h-2 w-2 bg-accent shadow-[0_0_5px_rgba(61,214,140,0.6)]" />
</span>
)
}
// ===== 卡片组件 =====
function StockCard({
@@ -331,7 +309,6 @@ function StockCard({
extCols,
expandedCells,
onToggleExpand,
isMonitored,
}: {
r: any
candleRows: KlineRow[]
@@ -344,7 +321,6 @@ function StockCard({
extCols: ColumnConfig[]
expandedCells: Set<string>
onToggleExpand: (key: string) => void
isMonitored?: boolean
}) {
const board = boardTag(r.symbol)
const price = r.rt_price ?? r.close
@@ -418,7 +394,6 @@ function StockCard({
{r.consecutive_limit_ups === 1 ? '首板' : `${r.consecutive_limit_ups}`}
</span>
)}
{isMonitored && <span className="ml-auto"><RealtimeDot /></span>}
</div>
{/* 第二行: 大价格 + 涨跌幅胶囊 */}
@@ -624,7 +599,6 @@ export function Watchlist() {
qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
@@ -647,20 +621,6 @@ export function Watchlist() {
const allSymbols = list.data?.symbols?.map(s => s.symbol) ?? []
const rows = enriched.data?.rows ?? []
// 实时监控圆点: 仅 Free/低档 "按自选股实时监控" 模式 (mode === 'watchlist') 下显示;
// Starter+ 全市场模式 (mode === 'full_market') 全部标的都在监控, 标圆点无意义, 故不显示。
// 后端 Free 档实际只监控自选页前 N 个 (N = watchlist_symbol_count), 顺序与 allSymbols 一致。
const quoteStatus = useQuoteStatus()
const realtimeRunning = quoteStatus.data?.running ?? false
const realtimeMode = quoteStatus.data?.mode
const watchlistMonitoredCount = quoteStatus.data?.watchlist_symbol_count ?? 0
const showRealtimeDot = realtimeRunning && realtimeMode === 'watchlist'
// 真正被监控的标的集合 (自选列表前 watchlistMonitoredCount 个)
const monitoredSymbols = useMemo(
() => showRealtimeDot ? new Set(allSymbols.slice(0, watchlistMonitoredCount)) : new Set<string>(),
[showRealtimeDot, allSymbols, watchlistMonitoredCount],
)
// ===== 筛选 =====
const [filterOpen, setFilterOpen] = useState(false)
const [filters, setFilters] = useState<Record<string, { min?: string; max?: string; text?: string }>>({})
@@ -1008,7 +968,6 @@ export function Watchlist() {
{board.label}
</span>
) : null}
{monitoredSymbols.has(r.symbol) && <span className="ml-2"><RealtimeDot /></span>}
</button>
{/* 删除入口:默认减号图标,二次确认时替换为确定按钮 */}
<div className="ml-auto pl-1 shrink-0">
@@ -1119,7 +1078,6 @@ export function Watchlist() {
extCols={visibleExtCols}
expandedCells={expandedCells}
onToggleExpand={handleToggleExpand}
isMonitored={monitoredSymbols.has(r.symbol)}
/>
))}
</div>
@@ -1,572 +1,8 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { useQueryClient, useMutation, useQuery } from '@tanstack/react-query'
import {
Activity,
Wifi,
BarChart3,
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 { tierRank } from '@/lib/capability-labels'
import { toast } from '@/components/Toast'
import { DepthConfigContent } from '@/components/data/DepthConfigCard'
// 页面 → 显示名
const PAGE_LABELS: Record<string, string> = {
'overview-market': '看板',
watchlist: '自选页',
'limit-ladder': '连板梯队',
}
const SIDEBAR_INDEX_OPTIONS = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
{ symbol: '399006.SZ', name: '创业板指' },
{ symbol: '000680.SH', name: '科创综指' },
]
// ===== 导出为 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()
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
const isFreeTier = tier === 0
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
const refreshPages = prefs?.sse_refresh_pages ?? {}
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
const hasDepth = !!caps?.capabilities?.['depth5.batch']
// 新建监控规则时是否默认勾选飞书推送 (全局默认值, 单条规则可独立修改)
const webhookDefault = prefs?.webhook_enabled_default ?? false
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
const interval = intervalData?.interval ?? 10
const minInterval = intervalData?.min_interval ?? 5
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('')
// 飞书渠道配置区展开态 (推送通知卡片内)
const [channelOpen, setChannelOpen] = useState(false)
useEffect(() => {
setFeishuDraft(feishuWebhookUrl)
setFeishuSecretDraft(feishuWebhookSecret)
}, [feishuWebhookUrl, feishuWebhookSecret])
const watchlistSymbols = prefs?.realtime_watchlist_symbols ?? []
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: () => api.watchlistList(),
enabled: isFreeTier && watchlistSymbols.length > 0,
})
const watchlistNameBySymbol = new Map(
(watchlist.data?.symbols ?? []).map(row => [row.symbol, row.name] as const),
)
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 toggleSidebarIndex = useCallback((symbol: string, visible: boolean) => {
const selected = new Set(sidebarIndexSymbols)
if (visible) selected.add(symbol)
else selected.delete(symbol)
const next = SIDEBAR_INDEX_OPTIONS
.map(item => item.symbol)
.filter(s => selected.has(s))
save({ sidebar_index_symbols: next })
}, [save, sidebarIndexSymbols])
const toggleIndicesPin = useCallback((pinned: boolean) => {
api.updateIndicesNavPinned(pinned).then(() => qc.invalidateQueries({ queryKey: QK.preferences }))
}, [qc])
const toggleLimitLadderMonitor = useCallback(async (enabled: boolean) => {
await api.updateLimitLadderMonitor(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
const toggleWebhookDefault = useCallback(async (enabled: boolean) => {
await api.updateWebhookDefault(enabled)
qc.invalidateQueries({ queryKey: QK.preferences })
}, [qc])
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 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])
// highlight=depth-fix 时闪烁高亮连板梯队修正卡片
const [flash, setFlash] = useState(false)
const flashedRef = useRef(false)
useEffect(() => {
if (highlight === 'depth-fix' && !flashedRef.current) {
flashedRef.current = true
// 延迟一帧确保 DOM 已渲染, 再触发闪烁
requestAnimationFrame(() => {
setFlash(true)
const t = setTimeout(() => setFlash(false), 2000)
return () => clearTimeout(t)
})
}
}, [highlight])
if (isNoneTier) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl
bg-gradient-to-br from-purple-500/20 to-blue-500/20 mb-5">
<Activity className="h-7 w-7 text-purple-400" />
</div>
<h2 className="text-lg font-medium text-foreground mb-2"></h2>
<p className="text-sm text-secondary max-w-md mb-6">
Free None 使 free-api K1-2
</p>
<a
href="/settings?tab=account"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-btn
bg-accent text-white text-sm font-medium
hover:bg-accent/90 transition-colors"
>
API Key
</a>
</div>
)
}
// Real-time monitoring settings have been removed.
export function SettingsMonitoringPanel(_props?: { highlight?: string }) {
return (
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1fr] gap-6 max-w-5xl">
{/* ========== 左列 ========== */}
<div className="space-y-6">
{/* 行情状态 — 开关 + 间隔 */}
<Card icon={Activity} title="行情轮询">
<ToggleRow
label="实时行情"
desc={isRunning && isTrading ? '运行中' : isRunning ? '运行中 (非交易时段)' : '已关闭'}
checked={realtimeEnabled}
onChange={handleToggleQuote}
/>
<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">
{isFreeTier ? '每轮拉取自选股实时行情的时间间隔' : '每轮拉取全市场行情的时间间隔'}
</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>
{isFreeTier && (
<Card icon={Activity} title="自选股实时">
<div className="mb-3 rounded-btn border border-accent/25 bg-accent/10 px-3 py-2 text-xs font-medium leading-snug text-accent">
Free 5 6
</div>
{watchlistSymbols.length > 0 ? (
<div className="space-y-1.5">
{watchlistSymbols.map(symbol => {
const name = watchlistNameBySymbol.get(symbol)
return (
<div key={symbol} className="flex items-center justify-between rounded-btn bg-base/50 border border-border px-2 py-1.5">
<div className="min-w-0 flex items-baseline gap-1.5">
<span className="text-xs font-mono text-foreground">{symbol}</span>
{name && <span className="truncate text-[11px] text-secondary">{name}</span>}
</div>
<span className="text-[10px] text-muted shrink-0"></span>
</div>
)
})}
</div>
) : (
<div className="rounded-btn border border-border bg-base/40 px-3 py-3 text-xs text-muted">
Free
</div>
)}
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-[10px] text-muted"> {watchlistSymbols.length}/5 </span>
<Link
to="/watchlist"
className="px-3 py-1 rounded-btn bg-elevated text-secondary text-xs font-medium hover:text-foreground transition-colors"
>
</Link>
</div>
</Card>
)}
{!isFreeTier && (
<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>
)}
{!isFreeTier && (
<Card icon={BarChart3} title="左侧菜单指数">
<p className="text-xs text-secondary mb-4">
</p>
<div className="space-y-2">
{SIDEBAR_INDEX_OPTIONS.map(item => (
<ToggleRow
key={item.symbol}
label={item.name}
desc={item.symbol}
checked={sidebarIndexSymbols.includes(item.symbol)}
onChange={(v) => toggleSidebarIndex(item.symbol, v)}
/>
))}
</div>
<div className="mt-3 pt-3 border-t border-border">
<ToggleRow
label="固定显示"
desc={indicesPinned ? '指数卡片常驻显示(即使实时行情关闭)' : '跟随实时行情开关(仅实时开时显示)'}
checked={indicesPinned}
onChange={toggleIndicesPin}
/>
</div>
</Card>
)}
</div>
{/* ========== 右列 ========== */}
<div className="space-y-6">
{/* 连板梯队降级修正 (移至右列顶部) */}
<div
id="depth-fix"
className={`rounded-card transition-all duration-500 ${flash ? 'ring-2 ring-accent/60 ring-offset-2 ring-offset-base scale-[1.01]' : 'ring-0 ring-transparent'}`}
>
<Card
icon={Flame}
title="连板梯队降级修正"
badge={!hasDepth ? '需 Pro+' : 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>
</div>
{/* 推送通知 — 监控告警的外部推送渠道 (全局配置)。
飞书已实现; 微信开发中, QMT/ptrade 待定。
每个渠道合并成一行: 勾选=新建规则默认推送, 点行展开地址配置。 */}
<Card icon={Webhook} title="推送通知">
<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={webhookDefault}
onChange={e => { e.stopPropagation(); toggleWebhookDefault(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"></span>
{webhookDefault && (
<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></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>
{/* 占位渠道 — 不可点 */}
{[
{ name: '微信', hint: '公众号/企业微信', status: '开发中' },
{ name: 'QMT', hint: '量化交易终端', status: '待定' },
{ name: 'ptrade', hint: '量化交易终端', status: '待定' },
].map(ch => (
<div
key={ch.name}
className="flex items-center gap-2 rounded-btn border border-border/40 bg-base/20 px-2.5 py-2 opacity-60"
>
<input type="checkbox" disabled className="h-3 w-3 accent-accent" />
<span className="text-[11px] text-secondary">{ch.name}</span>
<span className="text-[9px] text-muted">{ch.hint}</span>
<span className="ml-auto rounded bg-muted/10 px-1 py-px text-[9px] text-muted">{ch.status}</span>
</div>
))}
</div>
</Card>
</div>
<div className="flex flex-col items-center justify-center py-16 text-center">
<p className="text-sm text-muted"></p>
</div>
)
}
// ===== ToggleRow =====
function ToggleRow({
label,
desc,
checked,
onChange,
icon: Icon,
}: {
label: string
desc: string
checked: boolean
onChange: (v: boolean) => void
icon?: React.ComponentType<{ className?: string }>
}) {
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={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${
checked ? 'bg-accent' : 'bg-elevated'
}`}
>
<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 }: CardProps) {
return (
<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>
)
}
+5 -2
View File
@@ -7,7 +7,6 @@ import { useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react'
import { usePreferences, useVersion } from '@/lib/useSharedQueries'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { PageHeader } from '@/components/PageHeader'
import { refreshAlertToastConfig } from '@/components/AlertToast'
@@ -40,7 +39,11 @@ export function SettingsSystemPanel() {
const save = useCallback(async (cfg: Record<string, unknown>) => {
setSaving(true)
try {
await api.updateRealtimeMonitorConfig(cfg)
await fetch('/api/settings/preferences/realtime-monitor', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
})
qc.invalidateQueries({ queryKey: QK.preferences })
} finally {
setSaving(false)