@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 ?? []
|
||||
|
||||
@@ -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 获取历史日K(当日数据需盘后1-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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user