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

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
+3 -207
View File
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { NavLink, Outlet } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useQuoteStream } from '@/lib/useQuoteStream'
import { ToastContainer } from '@/components/Toast'
import { AlertToastContainer } from '@/components/AlertToast'
import { AiAnalysisHost } from '@/components/financials/AiAnalysisHost'
@@ -13,14 +12,9 @@ import {
useCapabilities,
useSettings,
usePreferences,
useQuoteStatus,
useVersion,
} from '@/lib/useSharedQueries'
import {
useToggleRealtimeQuotes,
} from '@/lib/useSharedMutations'
import { QK } from '@/lib/queryKeys'
import { tierRank } from '@/lib/capability-labels'
import {
Star,
ScanSearch,
@@ -42,26 +36,14 @@ import {
RadioTower,
CheckCircle2,
BookOpenCheck,
ExternalLink,
X,
} from 'lucide-react'
import { Logo } from './Logo'
import { api, type IndexQuote } from '@/lib/api'
import { api } from '@/lib/api'
import { cn } from '@/lib/cn'
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
const BRAND = '#8B5CF6'
const TICKFLOW_REGISTER_URL = 'https://tickflow.org/auth/register?ref=V3KDKGXPEA'
const CORE_INDEXES = [
{ symbol: '000001.SH', name: '上证指数' },
{ symbol: '399001.SZ', name: '深证成指' },
{ symbol: '399006.SZ', name: '创业板指' },
{ symbol: '000680.SH', name: '科创综指' },
] as const
type CoreIndex = (typeof CORE_INDEXES)[number]
const nav = [
{ to: '/', label: '看板', icon: LayoutDashboard },
@@ -80,23 +62,6 @@ const nav = [
{ to: '/data', label: '数据', icon: Database },
] as const
function fmtIndexValue(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return Number(v).toFixed(2)
}
function fmtIndexPct(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return '--'
return `${Number(v) >= 0 ? '+' : ''}${Number(v).toFixed(2)}%`
}
function indexPctClass(v: number | null | undefined) {
if (v == null || Number.isNaN(Number(v))) return 'text-muted'
const n = Number(v)
if (n === 0) return 'text-foreground'
return n > 0 ? 'text-bull' : 'text-bear'
}
/** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */
function MonitorBadge({ active }: { active: boolean }) {
const unread = useUnreadAlerts()
@@ -112,36 +77,6 @@ function MonitorBadge({ active }: { active: boolean }) {
)
}
function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; items: CoreIndex[] }) {
if (items.length === 0) return null
const quoteBySymbol = new Map((rows ?? []).map(q => [q.symbol, q]))
return (
<div className="mt-2 grid grid-cols-2 gap-1.5">
{items.map(item => {
const q = quoteBySymbol.get(item.symbol)
const value = q?.last_price ?? q?.close
const pct = q?.change_pct
return (
<NavLink
key={item.symbol}
to={`/indices?symbol=${encodeURIComponent(item.symbol)}`}
className="block rounded bg-elevated/60 px-2 py-1.5 transition-colors hover:bg-elevated"
title={`${item.name} ${item.symbol}`}
>
<div className="flex items-center justify-between gap-1">
<span className="text-[10px] text-secondary">{item.name}</span>
<span className={`text-[10px] font-mono ${indexPctClass(pct)}`}>{fmtIndexPct(pct)}</span>
</div>
<div className="mt-0.5 truncate font-mono text-[10px] text-foreground/80">
{fmtIndexValue(value)}
</div>
</NavLink>
)
})}
</div>
)
}
// ===== 档位卡片 =====
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
const base = label.split(' ')[0].split('+')[0].toLowerCase()
@@ -262,8 +197,6 @@ export function Layout() {
const { data: settingsState } = useSettings()
const { data: versionData } = useVersion()
const { data: prefs } = usePreferences()
// poll=true: 全局唯一开启条件轮询 (非交易时段 60s 兜底, 交易时段靠 SSE)
const { data: quoteStatus } = useQuoteStatus({ poll: true })
const { data: analysisMenus } = useQuery({
queryKey: QK.analysisMenus,
queryFn: api.analysisMenus,
@@ -293,34 +226,7 @@ export function Layout() {
prevSyncingRef.current = isDataSyncing
}, [isDataSyncing])
const qc = useQueryClient()
const navigate = useNavigate()
const version = versionData?.version
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
// Free 档监控限制提示: 可手动关闭, 不持久化 (刷新后恢复显示)
const [dismissFreeHint, setDismissFreeHint] = useState(false)
const indicesPinned = prefs?.indices_nav_pinned ?? true
const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? CORE_INDEXES.map(p => p.symbol)
const sidebarIndexes = CORE_INDEXES.filter(item => sidebarIndexSymbols.includes(item.symbol))
// 卡片数据:固定显示时也拉取(即使实时行情关闭)
const showSidebarQuotes = indicesPinned || realtimeEnabled
const { data: sidebarIndexQuotes } = useQuery({
queryKey: [...QK.indexQuotes, 'sidebar', sidebarIndexSymbols.join(',')] as const,
queryFn: () => api.indexQuotes(sidebarIndexes.map(p => p.symbol)),
enabled: showSidebarQuotes && sidebarIndexes.length > 0,
placeholderData: (prev) => prev,
})
// SSE: 行情更新时自动刷新相关 queries + 告警通知
useQuoteStream(realtimeEnabled, prefs?.sse_refresh_pages)
const toggleQuote = useToggleRealtimeQuotes()
const isRunning = quoteStatus?.running ?? false
const isTrading = quoteStatus?.is_trading_hours ?? false
const tier = tierRank(caps?.label ?? '')
const isNoneTier = tier < 0
const isWatchlistMode = tier === 0
const realtimeModeLabel = isWatchlistMode ? '自选股' : '全市场'
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
const alertsTotalQuery = useQuery({
@@ -358,27 +264,6 @@ export function Layout() {
const hiddenIds = new Set(prefs?.nav_hidden ?? [])
const visibleNavItems = navItems.filter(n => !hiddenIds.has(n.to) && !hiddenIds.has(n.to.replace(/^\/analysis\//, '')))
const handleToggle = async (enabled: boolean) => {
// 开启时重新校验档位
if (enabled) {
const fresh = await qc.fetchQuery({
queryKey: QK.capabilities,
queryFn: api.capabilities,
})
const freshTier = tierRank(fresh.label ?? '')
if (freshTier < 0) return
if (freshTier === 0 && (prefs?.realtime_watchlist_symbols?.length ?? 0) === 0) {
navigate('/watchlist')
return
}
}
await toggleQuote.mutateAsync(enabled)
// 仅在交易时段立即获取一次行情
if (enabled && isTrading) {
api.intradayRefresh().catch(() => {})
}
}
return (
<div className="h-screen grid grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden">
@@ -457,95 +342,6 @@ export function Layout() {
))}
</nav>
{/* 全局行情开关 */}
<div className="border-t border-border px-3 py-2.5 shrink-0">
{isNoneTier ? (
<div>
<div className="flex items-center justify-between">
<span className="text-xs text-secondary truncate"></span>
<span className="text-[10px] text-accent/70 font-medium bg-accent/10 px-1.5 py-0.5 rounded">
Free+
</span>
</div>
<div className="mt-1.5 text-[10px] leading-snug text-muted">
<a
href={TICKFLOW_REGISTER_URL}
target="_blank"
rel="noreferrer"
className="mx-1 inline-flex items-baseline gap-0.5 text-accent/80 hover:text-accent hover:underline"
>
TickFlow
<ExternalLink className="h-2.5 w-2.5 self-center" />
</a>
</div>
</div>
) : (
/* Starter+ — 开关 + 跳转设置 */
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className={`inline-block h-1.5 w-1.5 rounded-full shrink-0 ${
realtimeEnabled && isRunning && isTrading
? 'bg-accent animate-pulse'
: realtimeEnabled
? 'bg-warning/60'
: 'bg-muted'
}`} />
<span className="text-xs text-secondary truncate">
· {realtimeModeLabel}
</span>
<button
onClick={() => navigate('/settings?tab=monitoring')}
className="text-secondary hover:text-foreground transition-colors shrink-0"
title="实时监控设置"
>
<Settings className="h-3 w-3" />
</button>
</div>
<button
onClick={() => handleToggle(!realtimeEnabled)}
disabled={toggleQuote.isPending}
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
realtimeEnabled
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
: 'bg-elevated'
} ${toggleQuote.isPending ? 'opacity-50' : 'cursor-pointer'}`}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
realtimeEnabled ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
</div>
)}
{/* 状态提示 */}
{realtimeEnabled && !isNoneTier && (
<div className="mt-1.5 text-[10px] leading-snug space-y-0.5">
{isWatchlistMode && !dismissFreeHint && (
<div className="flex items-start gap-1 text-amber-400/80">
<span className="flex-1"> 5 Starter+</span>
<button
onClick={() => setDismissFreeHint(true)}
className="text-amber-400/50 hover:text-amber-400 shrink-0 transition-colors"
title="关闭提示"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
)}
{isRunning && isTrading ? (
<div className="text-accent"></div>
) : realtimeEnabled && !isTrading ? (
<div className="text-warning/70"></div>
) : null}
</div>
)}
{showSidebarQuotes && !isWatchlistMode && !isNoneTier && (
<SidebarIndexQuotes rows={sidebarIndexQuotes?.rows} items={sidebarIndexes} />
)}
</div>
<div className="border-t border-border px-2 py-3 space-y-0.5 shrink-0">
<NavLink
to="/settings"
@@ -1,143 +0,0 @@
import { useState, useEffect } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences, useCapabilities } from '@/lib/useSharedQueries'
import { isExpertOrAbove } from '@/lib/capability-labels'
/**
* 五档盘口 sealed(真假涨停) 配置内容(纯内容, 无外框, 由父级 Card 包裹)。
*
* - 轮询间隔: Pro 10~120s / Expert 3~300s
* - 盘后定版时间: 15:01~18:00, 默认 15:02
* - disabled 时(监控关闭)输入框禁用
*/
// 注: 文件名保留 DepthConfigCard.tsx, 导出 DepthConfigContent(纯内容无外框)
export function DepthConfigContent({ disabled }: { disabled?: boolean }) {
const qc = useQueryClient()
const prefs = usePreferences()
const caps = useCapabilities()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const tierLabel = caps.data?.label ?? ''
const range = isExpertOrAbove(tierLabel) ? { lo: 3, hi: 300 } : { lo: 10, hi: 120 }
const interval = prefs.data?.depth_polling_interval ?? 20
const finalizeTime = prefs.data?.depth_finalize_time ?? { hour: 15, minute: 2 }
const [intervalInput, setIntervalInput] = useState(String(Math.round(interval)))
const [finalizeHour, setFinalizeHour] = useState(String(finalizeTime.hour))
const [finalizeMinute, setFinalizeMinute] = useState(String(finalizeTime.minute))
useEffect(() => { setIntervalInput(String(Math.round(interval))) }, [interval])
useEffect(() => {
setFinalizeHour(String(finalizeTime.hour))
setFinalizeMinute(String(finalizeTime.minute))
}, [finalizeTime.hour, finalizeTime.minute])
const saveInterval = useMutation({
mutationFn: (v: number) => api.updateDepthPollingInterval(v),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const saveFinalize = useMutation({
mutationFn: ({ hour, minute }: { hour: number; minute: number }) =>
api.updateDepthFinalizeTime(hour, minute),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
// 无能力: 显示升级提示
if (!hasDepth) {
return (
<p className="text-xs text-muted leading-relaxed">
, <span className="text-accent">Pro </span>
()()
</p>
)
}
const inputCls = `w-16 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`
return (
<div className="space-y-3">
{/* 盘中轮询间隔 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> {range.lo}~{range.hi} · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={range.lo}
max={range.hi}
value={intervalInput}
disabled={disabled}
onChange={e => setIntervalInput(e.target.value)}
onBlur={() => {
if (disabled) return
let v = Number(intervalInput)
if (!Number.isFinite(v)) v = range.lo
v = Math.max(range.lo, Math.min(range.hi, v))
saveInterval.mutate(v)
}}
className={inputCls}
/>
<span className="text-xs text-muted"></span>
</div>
</div>
{/* 盘后定版时间 */}
<div className="flex items-center justify-between gap-2">
<div className={disabled ? 'opacity-50' : ''}>
<div className="text-xs text-secondary"></div>
<div className="text-[10px] text-muted"> 15:01~18:00 · </div>
</div>
<div className="flex items-center gap-1">
<input
type="number"
min={15}
max={18}
value={finalizeHour}
disabled={disabled}
onChange={e => setFinalizeHour(e.target.value)}
onBlur={() => {
if (disabled) return
let h = Number(finalizeHour)
if (!Number.isFinite(h)) h = 15
h = Math.max(15, Math.min(18, h))
let m = Number(finalizeMinute)
if (!Number.isFinite(m)) m = 2
m = Math.max(0, Math.min(59, m))
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
saveFinalize.mutate({ hour: h, minute: m })
}}
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
/>
<span className="text-xs text-muted">:</span>
<input
type="number"
min={0}
max={59}
value={finalizeMinute}
disabled={disabled}
onChange={e => setFinalizeMinute(e.target.value)}
onBlur={() => {
if (disabled) return
let h = Number(finalizeHour)
if (!Number.isFinite(h)) h = 15
h = Math.max(15, Math.min(18, h))
let m = Number(finalizeMinute)
if (!Number.isFinite(m)) m = 2
m = Math.max(0, Math.min(59, m))
if (h * 60 + m < 15 * 60 + 1) { h = 15; m = 1 }
if (h * 60 + m > 18 * 60) { h = 18; m = 0 }
saveFinalize.mutate({ hour: h, minute: m })
}}
className={`w-12 h-7 bg-elevated border border-border rounded text-xs text-center px-1 focus:outline-none focus:border-accent/50 ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
/>
</div>
</div>
</div>
)
}
@@ -1,165 +0,0 @@
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Activity, Settings } from 'lucide-react'
import { Skeleton } from './Skeleton'
export function QuoteConfigCard({ enabled, running, isTrading, lastFetchMs, intervalS, intervalMin, intervalMax, loading, onToggle, toggling, showIntervalEdit, onShowIntervalEdit, onIntervalChange }: {
enabled: boolean
running: boolean
isTrading: boolean
lastFetchMs: number | null
intervalS: number
intervalMin: number
intervalMax: number
loading: boolean
onToggle: (enabled: boolean) => void
toggling: boolean
showIntervalEdit: boolean
onShowIntervalEdit: () => void
onIntervalChange: (v: number) => void
}) {
const statusColor = running && isTrading
? 'bg-accent shadow-[0_0_6px_rgba(61,214,140,0.5)]'
: enabled && running
? 'bg-warning/60'
: 'bg-muted'
const statusText = !enabled
? '已关闭'
: !isTrading
? '非交易时段'
: running
? '行情运行中'
: '已停止'
const lastFetchTime = lastFetchMs
? new Date(lastFetchMs).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })
: null
return (
<div className="rounded-card border border-border bg-surface p-4 relative">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-secondary" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<button
onClick={() => onToggle(!enabled)}
disabled={toggling}
className={`relative inline-flex h-4 w-7 items-center rounded-full shrink-0 transition-colors duration-200 ${
enabled
? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]'
: 'bg-elevated'
} ${toggling ? 'opacity-50' : 'cursor-pointer'}`}
>
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
enabled ? 'translate-x-[14px]' : 'translate-x-0.5'
}`} />
</button>
</div>
{loading ? (
<div className="space-y-2">
<div className="flex items-center justify-between"><Skeleton w="w-8" /><Skeleton w="w-16" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-20" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-10" /><Skeleton w="w-14" /></div>
<div className="flex items-center justify-between"><Skeleton w="w-12" /><Skeleton w="w-12" /></div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<div className="flex items-center gap-1.5">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${statusColor} ${running && isTrading ? 'animate-pulse' : ''}`} />
<span className="font-mono text-secondary">{statusText}</span>
</div>
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className={`font-mono ${isTrading ? 'text-accent' : 'text-muted'}`}>{isTrading ? '交易中' : '休市'}</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<div className="flex items-center gap-1">
<span className="text-muted"></span>
<button
onClick={() => onShowIntervalEdit()}
className={`p-0.5 rounded hover:bg-elevated transition-colors ${showIntervalEdit ? 'text-accent' : 'text-secondary'}`}
title="设置轮询间隔"
>
<Settings className="h-3 w-3" />
</button>
</div>
<span className="font-mono text-secondary">{intervalS}s</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted"></span>
<span className="font-mono text-secondary">{lastFetchTime ?? '—'}</span>
</div>
</div>
)}
<AnimatePresence>
{showIntervalEdit && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="overflow-hidden"
>
<IntervalEditor
min={intervalMin}
max={intervalMax}
value={intervalS}
onChange={onIntervalChange}
/>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function IntervalEditor({ min, max, value, onChange }: {
min: number; max: number; value: number; onChange: (v: number) => void
}) {
const [draft, setDraft] = useState(value)
const clamped = Math.max(min, Math.min(max, draft))
const step = min < 1 ? 0.1 : min < 3 ? 0.5 : 1
const presets = min <= 3 ? [3, 5, 10, 30, 60] : [5, 10, 15, 30, 60]
return (
<div className="mt-2 pt-2 border-t border-border/50">
<div className="text-[10px] text-muted mb-1.5">
<span className="text-muted/60">({min}s ~ {max}s)</span>
</div>
<div className="flex flex-wrap gap-1 mb-2">
{presets.map(p => (
<button
key={p}
onClick={() => { setDraft(p); onChange(p) }}
className={`px-1.5 py-0.5 rounded text-[10px] font-mono transition-colors ${
Math.abs(clamped - p) < 0.01
? 'bg-accent/15 text-accent border border-accent/30'
: 'bg-elevated text-secondary hover:text-foreground border border-transparent'
}`}
>
{p}s
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
type="range"
min={min} max={max} step={step}
value={clamped}
onChange={e => { const v = parseFloat(e.target.value); setDraft(v); onChange(v) }}
className="flex-1 h-1 accent-accent cursor-pointer"
/>
<span className="text-[10px] font-mono text-foreground w-8 text-right">
{clamped < 1 ? clamped.toFixed(1) : clamped.toFixed(0)}s
</span>
</div>
</div>
)
}
@@ -45,14 +45,14 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const options = useQuery({ queryKey: QK.monitorRuleOptions, queryFn: api.monitorRuleOptions })
const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies })
const { data: prefs } = usePreferences()
const feishuConfigured = !!(prefs?.feishu_webhook_url)
const feishuConfigured = !!((prefs as any)?.feishu_webhook_url)
const [editing] = useState(!!rule)
// 新建规则: 预填全局「默认推送渠道」(飞书), preset 显式指定时以 preset 为准。
// 编辑规则: 完全沿用规则自身配置, 不受默认值影响。
const [draft, setDraft] = useState<MonitorRule>(
rule
? { ...rule, conditions: rule.conditions.map(c => ({ ...c })) }
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!(prefs?.webhook_enabled_default) },
: { ...emptyRule(preset), webhook_enabled: preset?.webhook_enabled ?? !!((prefs as any)?.webhook_enabled_default) },
)
const [error, setError] = useState('')
const [symbolQuery, setSymbolQuery] = useState('')
-73
View File
@@ -669,20 +669,12 @@ export interface SaveTickflowKeyResult {
}
export interface Preferences {
realtime_quotes_enabled: boolean
indices_nav_pinned: boolean
minute_sync_enabled: boolean
minute_sync_days: number
daily_data_provider?: string
adj_factor_provider?: string
minute_data_provider?: string
realtime_data_provider?: string
realtime_watchlist_symbols?: string[]
realtime_pull_stock?: boolean
realtime_pull_etf?: boolean
realtime_pull_index?: boolean
realtime_index_mode?: 'core' | 'all'
realtime_index_symbols?: string[]
pipeline_pull_a_share: boolean
pipeline_pull_etf: boolean
pipeline_pull_index: boolean
@@ -691,18 +683,9 @@ export interface Preferences {
instruments_schedule: { hour: number; minute: number }
enriched_batch_size: number
index_daily_batch_size: number
limit_ladder_monitor_enabled: boolean
depth_polling_interval: number
depth_finalize_time: { hour: number; minute: number }
review_schedule: { enabled: boolean; hour: number; minute: number }
review_push_channels: string[]
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
system_notify_enabled: boolean
feishu_webhook_url?: string
feishu_webhook_secret?: string
webhook_enabled_default?: boolean
sidebar_index_symbols: string[]
nav_order: string[]
nav_hidden: string[]
@@ -793,67 +776,11 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ symbols }),
}),
updateRealtimeQuotes: (enabled: boolean) =>
request<{ realtime_quotes_enabled: boolean; realtime_allowed?: boolean; mode?: string; error?: string }>('/api/settings/preferences/realtime-quotes', {
method: 'PUT',
body: JSON.stringify({ realtime_quotes_enabled: enabled }),
}),
updateRealtimeQuoteScope: (cfg: Partial<Pick<Preferences, 'realtime_pull_stock' | 'realtime_pull_etf' | 'realtime_pull_index' | 'realtime_index_mode' | 'realtime_index_symbols'>>) =>
request<Partial<Preferences>>('/api/settings/preferences/realtime-quote-scope', {
method: 'PUT',
body: JSON.stringify(cfg),
}),
updateIndicesNavPinned: (pinned: boolean) =>
request<{ indices_nav_pinned: boolean }>('/api/settings/preferences/indices-nav-pinned', {
method: 'PUT',
body: JSON.stringify({ indices_nav_pinned: pinned }),
}),
quoteStatus: () =>
request<{
enabled: boolean
running: boolean
mode?: 'none' | 'watchlist' | 'full_market'
realtime_allowed?: boolean
interval_s: number
symbol_count: number
watchlist_symbol_count?: number
index_symbol_count?: number
etf_symbol_count?: number
quote_age_ms: number | null
is_trading_hours: boolean
last_fetch_ms: number | null
}>('/api/intraday/status'),
quoteInterval: () =>
request<{ interval: number; min_interval: number; max_interval: number }>(
'/api/settings/preferences/quote-interval',
),
updateQuoteInterval: (interval: number) =>
request<{ interval: number; min_interval: number; max_interval: number }>(
'/api/settings/preferences/quote-interval',
{ method: 'PUT', body: JSON.stringify({ interval }) },
),
intradayRefresh: () => request<{ status: string }>('/api/intraday/refresh', { method: 'POST' }),
indexQuotes: (symbols?: string[]) =>
request<{ rows: IndexQuote[]; count: number }>(
`/api/intraday/indices${symbols?.length ? `?symbols=${encodeURIComponent(symbols.join(','))}` : ''}`,
),
updateRealtimeMonitorConfig: (cfg: {
sse_refresh_pages?: Record<string, boolean>
strategy_monitor_enabled?: boolean
strategy_monitor_ids?: string[]
sidebar_index_symbols?: string[]
screener_auto_run?: boolean
}) =>
request<{
sse_refresh_pages: Record<string, boolean>
strategy_monitor_enabled: boolean
strategy_monitor_ids: string[]
sidebar_index_symbols: string[]
screener_auto_run: boolean
}>('/api/settings/preferences/realtime-monitor', {
method: 'PUT',
body: JSON.stringify(cfg),
}),
updateSystemNotify: (enabled: boolean) =>
request<{ system_notify_enabled: boolean }>('/api/settings/preferences/system-notify', {
method: 'PUT',
+1 -2
View File
@@ -9,7 +9,6 @@ export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
'kline.minute.batch': { name: '分钟 K(批量)', hint: '多股分钟 K' },
'depth5': { name: '五档盘口', hint: '买卖五档报价' },
'websocket': { name: '实时推送(WS)', hint: '免轮询的实时行情订阅' },
'financial': { name: '财务数据', hint: '利润表 / 资负表 / 现金流 / 关键指标' },
'adj_factor': { name: '复权因子', hint: '让 MA/MACD 等指标在分红送转日不失真' },
}
@@ -63,7 +62,7 @@ const TIER_STYLE: Record<string, TierStyle> = {
labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
},
expert: {
desc: 'WebSocket · 财务数据',
desc: '财务数据',
tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' },
dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' },
labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' },
+1 -15
View File
@@ -2,7 +2,6 @@
* 集中管理所有 React Query key。
*
* - 新增查询只需在此加一行,所有消费方自动引用。
* - SSE invalidation 基于 SSE_INVALIDATE_PREFIXES 列表,新增 key 无需改 useQuoteStream。
*/
// ===== Query Key 工厂 =====
@@ -14,10 +13,7 @@ export const QK = {
endpoints: ['endpoints'] as const,
version: ['version'] as const,
preferences: ['preferences'] as const,
quoteStatus: ['quote-status'] as const,
quoteInterval: ['quote-interval'] as const,
overviewMarket: (asOf?: string) => ['overview-market', asOf ?? 'latest'] as const,
indexQuotes: ['index-quotes'] as const,
indexList: ['index-list'] as const,
// Watchlist
@@ -78,14 +74,4 @@ export const QK = {
rpsRotation: (days: number) => ['rps-rotation', days] as const,
} as const
// ===== SSE 应该 invalidate 的 key 前缀列表 =====
// 新增需要 SSE 推送的查询,只需在此加一行
export const SSE_INVALIDATE_PREFIXES = [
'watchlist',
'quote-status',
'index-quotes',
'overview-market',
'limit-ladder',
'screener',
] as const
// SSE invalidation has been removed along with real-time functionality.
+2 -58
View File
@@ -35,10 +35,6 @@ const INITIAL: ReviewState = { phase: 'idle', content: '', error: '', meta: null
let state: ReviewState = { ...INITIAL }
let abortCtrl: AbortController | null = null
// 当前生成来源: 'manual'(手动点生成) | 'sse'(定时任务 SSE 推送) | null(空闲)
// 用于区分两条流, 避免互相丢弃事件或重复归档。
let generatingSource: 'manual' | 'sse' | null = null
// ===== 订阅机制 =====
type Listener = () => void
const listeners = new Set<Listener>()
@@ -80,7 +76,6 @@ export async function startReviewGeneration(
// 已在生成中,不重复启动
if (isReviewGenerating()) return
generatingSource = 'manual'
state = { phase: 'loading', content: '', error: '', meta: null, focus }
notify()
@@ -114,7 +109,7 @@ export async function startReviewGeneration(
if (buf && !failed) {
state = { ...state, phase: 'done' }
notify()
// 自动归档(仅手动流: 定时流由后端归档, SSE done 不走这里)
// 自动归档
if (buf && !failed) {
onDone?.(buf, doneMeta)
}
@@ -126,7 +121,6 @@ export async function startReviewGeneration(
}
} finally {
abortCtrl = null
generatingSource = null
}
}
@@ -167,54 +161,4 @@ export function resetReview(): void {
notify()
}
/**
* 喂入一条来自 SSE 的复盘事件(定时生成时后端推来的)。
*
* 用途: 定时复盘在后端流式生成, 通过 /api/intraday/stream 的 review_progress 事件
* 把 meta/delta/done 等实时推给前端, 前端调本函数把事件写进 store ——
* 这样开着复盘页的用户能看到「边生成边显示」, 和手动点生成完全一致。
*
* 事件格式与 recap_market_stream 产出一致:
* {type:'meta'|'delta'|'error'|'done'|'retry', ...}
*
* 与手动生成的并发:
* - 若手动正在生成(isReviewGenerating), 忽略 SSE 事件(手动流优先, 避免冲突)。
* - done 带 archived=true(定时场景后端已归档): 不重复调归档接口, 仅切到 done 态。
* - retry: 后端 LLM 断流重试, 清空已累积内容重新开始。
*/
export function feedReviewEvent(evt: any): void {
if (!evt || typeof evt !== 'object') return
const t = evt.type
// 并发控制: 手动流进行中时, SSE 事件一律忽略(手动流优先, 避免两条流抢同一个 store)
// 但若当前是 SSE 流自己在跑(generatingSource==='sse'), 则正常处理后续事件
if (generatingSource === 'manual') return
if (t === 'meta') {
// 定时流的第一个事件: 标记来源为 sse, 进入 streaming 态, 重置 content
generatingSource = 'sse'
state = { phase: 'streaming', content: '', error: '', meta: evt, focus: '' }
notify()
} else if (t === 'delta' && evt.content) {
// 只有 sse 流进行中时才累积(防止 meta 丢失时的孤立 delta)
if (generatingSource !== 'sse') return
state = { ...state, content: state.content + evt.content, phase: 'streaming' }
notify()
} else if (t === 'retry') {
if (generatingSource !== 'sse') return
// 后端重试: 清空已累积内容, 等待新一轮 meta/delta
state = { ...state, content: '', phase: 'streaming' }
notify()
} else if (t === 'error') {
if (generatingSource !== 'sse') return
state = { ...state, error: evt.message ?? '复盘生成失败', phase: 'error' }
notify()
generatingSource = null
} else if (t === 'done') {
if (generatingSource !== 'sse') return
// 定时场景 done 带 archived=true: 后端已归档, 前端只切 done 态, 不调归档接口。
state = { ...state, phase: 'done' }
notify()
generatingSource = null
}
}
// feedReviewEvent (SSE-only) removed along with real-time SSE channel.
+1 -4
View File
@@ -31,10 +31,7 @@ function loadConfig(): QueryConfig {
}
}
/**
* 轻量版:只读取当前配置。
* 供 useQuoteStream 使用。
*/
/** 轻量版:只读取当前配置。 */
export function getQueryConfig(): QueryConfig {
return loadConfig()
}
-147
View File
@@ -1,147 +0,0 @@
import { useEffect, useRef, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { SSE_INVALIDATE_PREFIXES, QK } from './queryKeys'
import { getQueryConfig } from './useQueryConfig'
import { toast } from '@/components/Toast'
import { pushAlertToasts } from '@/components/AlertToast'
import { feedReviewEvent } from './reviewStore'
import type { StrategyAlertEvent } from './api'
/**
* 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。
*
* - 行情更新 (quotes_updated): 根据 sseRefreshPages 配置过滤 invalidation
* - 策略监控通知 (strategy_alert): 通过 onAlert 回调弹 toast
*
* 应在顶层 Layout 中调用一次。
*/
export function useQuoteStream(
enabled: boolean,
sseRefreshPages: Record<string, boolean> | undefined,
onAlert?: (alerts: StrategyAlertEvent[]) => void,
) {
const qc = useQueryClient()
const esRef = useRef<EventSource | null>(null)
const retryRef = useRef<ReturnType<typeof setTimeout>>()
const pagesRef = useRef(sseRefreshPages)
pagesRef.current = sseRefreshPages
const handleAlerts = useCallback((alerts: StrategyAlertEvent[]) => {
// depth 系统接管通知: 单独处理, 不走 strategy 回调
const depthAlerts = alerts.filter(a => a.source === 'depth')
const strategyAlerts = alerts.filter(a => a.source !== 'depth')
// depth 通知直接 toast(防刷屏: 后端已在状态切换时才推)
for (const a of depthAlerts.slice(0, 1)) {
toast(a.message, 'success')
}
// 监控告警: 用专用 AlertToast (整批只响一声, 每条都弹, 受 maxVisible 上限保护)
if (strategyAlerts.length > 0) {
// 有 onAlert 回调时走回调, 否则弹 AlertToast
if (onAlert) {
onAlert(strategyAlerts)
}
// 批量弹通知 (去掉了 slice(0,2) 截断, 让每只新命中都弹 toast; 声音整批只响一次)
pushAlertToasts(strategyAlerts as any)
}
}, [onAlert])
const enabledRef = useRef(enabled)
enabledRef.current = enabled
useEffect(() => {
// SSE 始终连接 — 监控告警不依赖实时行情开关
// (quotes_updated 行情刷新受 enabled 控制, strategy_alert 始终处理)
const connect = () => {
const es = new EventSource('/api/intraday/stream')
esRef.current = es
// sse-starlette ping 心跳走 SSE comment,不会到达这里
es.addEventListener('quotes_updated', () => {
// 实时行情未开启时不处理行情刷新
if (!enabledRef.current) return
// 根据用户配置过滤 invalidation
const pages = pagesRef.current
if (pages) {
// 只 invalidate 开启的页面对应的 prefix
const activePrefixes = SSE_INVALIDATE_PREFIXES.filter((p) => {
// 'quote-status' 始终刷新 (全局状态)
if (p === 'quote-status') return true
return pages[p] !== false
})
qc.invalidateQueries({
predicate: (query) =>
activePrefixes.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
} else {
// 无配置时全部刷新 (向后兼容)
qc.invalidateQueries({
predicate: (query) =>
SSE_INVALIDATE_PREFIXES.some(
(prefix) => String(query.queryKey[0]).startsWith(prefix),
),
})
}
})
es.addEventListener('depth_updated', () => {
// 五档修正完成: 刷新连板梯队 + 看板封单数据。
// 不受实时行情开关限制 — 修正轮询独立于行情轮询, 用户开了修正就想看实时封单。
qc.invalidateQueries({ queryKey: ['limit-ladder'] })
qc.invalidateQueries({ queryKey: ['overview-market'] })
})
es.addEventListener('strategy_alert', (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
const alerts: StrategyAlertEvent[] = data.alerts || []
if (alerts.length > 0) {
handleAlerts(alerts)
// 实时刷新触发记录列表 + 监控中心徽标
qc.invalidateQueries({ queryKey: ['alerts'] })
qc.invalidateQueries({ queryKey: ['alerts-total'] })
}
} catch {
// 忽略解析错误
}
})
// 定时复盘流式进度: 后端到点生成时把 meta/delta/done 推来, 喂进 reviewStore
// 开着复盘页可看到「边生成边显示」, 切走再回来也能看到生成中/已生成
es.addEventListener('review_progress', (e: MessageEvent) => {
try {
const evt = JSON.parse(e.data)
feedReviewEvent(evt)
// done(后端已归档) → 刷新历史列表, 让新报告出现并可查看
if (evt.type === 'done') {
qc.invalidateQueries({ queryKey: QK.reviewReports })
}
} catch {
// 忽略解析错误
}
})
es.onerror = () => {
es.close()
esRef.current = null
const delay = getQueryConfig().sse.reconnectDelay
retryRef.current = setTimeout(connect, delay)
}
}
connect()
return () => {
clearTimeout(retryRef.current)
if (esRef.current) {
esRef.current.close()
esRef.current = null
}
}
}, [qc, handleAlerts])
}
@@ -5,30 +5,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './api'
import { QK } from './queryKeys'
/** 切换实时行情 — Layout / Data 共用 */
export function useToggleRealtimeQuotes() {
const qc = useQueryClient()
return useMutation({
mutationFn: (enabled: boolean) => api.updateRealtimeQuotes(enabled),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.preferences })
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
}
/** 更新行情轮询间隔 — Layout / Data 共用 */
export function useUpdateQuoteInterval() {
const qc = useQueryClient()
return useMutation({
mutationFn: (v: number) => api.updateQuoteInterval(v),
onSuccess: (data) => {
qc.setQueryData(QK.quoteInterval, data)
qc.invalidateQueries({ queryKey: QK.quoteStatus })
},
})
}
/** 批量添加自选 — Screener / Intraday 共用 */
export function useWatchlistBatchAdd() {
const qc = useQueryClient()
@@ -1,8 +1,5 @@
/**
* 共享 query hooks — 消除多页面重复的 useQuery 调用。
*
* 实时数据走 SSE invalidation,无需前端轮询。
* 只有管线进度等非 SSE 数据才用 refetchInterval。
*/
import { useQuery } from '@tanstack/react-query'
import { api } from './api'
@@ -34,33 +31,6 @@ export function usePreferences() {
})
}
/** 行情状态 — SSE quotes_updated 自动刷新。
* poll=true 时启用条件轮询兜底: 仅在非交易时段每 60s 轮询一次,
* 用于在交易时段边界 (11:30午休 / 12:55开盘 / 15:05收盘) 同步 is_trading_hours。
* 交易时段不轮询 (SSE 已驱动刷新), 非交易时段无 SSE 推送, 需要兜底。
* 只应在全局唯一挂载处 (Layout) 传 poll=true, 避免多页面重复轮询;
* 其他调用方共享同一 queryKey 缓存, 无需自行轮询。
*/
export function useQuoteStatus(opts?: { enabled?: boolean; poll?: boolean }) {
return useQuery({
queryKey: QK.quoteStatus,
queryFn: api.quoteStatus,
enabled: opts?.enabled ?? true,
refetchInterval: opts?.poll
? (query) => (query.state.data?.is_trading_hours ? false : 60_000)
: false,
})
}
/** 行情间隔 — Layout / Data 共用 */
export function useQuoteInterval() {
return useQuery({
queryKey: QK.quoteInterval,
queryFn: api.quoteInterval,
})
}
/** 版本号 — Layout 专用 */
export function useVersion() {
return useQuery({
+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)