@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user