@@ -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('')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -31,10 +31,7 @@ function loadConfig(): QueryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量版:只读取当前配置。
|
||||
* 供 useQuoteStream 使用。
|
||||
*/
|
||||
/** 轻量版:只读取当前配置。 */
|
||||
export function getQueryConfig(): QueryConfig {
|
||||
return loadConfig()
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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