diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bab4ac8..f9adbdb 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, Suspense } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState, Suspense } from 'react' import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' @@ -10,7 +10,7 @@ import { AiReportBubble } from '@/components/financials/AiReportBubble' import { StockAnalysisHost } from '@/components/stock-analysis/StockAnalysisHost' import { StockAnalysisBubble } from '@/components/stock-analysis/StockAnalysisBubble' import { - useCapabilities, + useCapabilityMatrix, useSettings, usePreferences, useQuoteStatus, @@ -53,7 +53,7 @@ import { PanelLeftOpen, } from 'lucide-react' import { Logo } from './Logo' -import { api, type IndexQuote } from '@/lib/api' +import { api, type CapabilityMatrix, type IndexQuote } from '@/lib/api' import { cn } from '@/lib/cn' import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' import { computeGroupPcts, groupPctColor, groupPctTitle } from '@/lib/watchlistGroupStats' @@ -171,80 +171,140 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i ) } -// ===== 档位卡片 ===== -function TierBadge({ label, hasKey, providerName, isTickflow }: { label: string; hasKey?: boolean; providerName: string; isTickflow: boolean }) { - const base = label.split(' ')[0].split('+')[0].toLowerCase() - const isNone = base === 'none' +// ===== 数据源能力健康卡 ===== +// 能力路由架构下的侧栏状态: 不再展示「主数据源 + TickFlow 档位」(单源时代遗留 — +// 五个能力各自路由, 拿日K的源代表全局是随意的), 改为回答「各能力当前是否都有源在供」。 +// 档位/订阅信息归设置页 TickFlow 介绍卡 (档位词仅出现在 TickFlow 专属界面的设计规则)。 +// 单能力方格: 可用=绿 / 日K缺失=红 / 其他缺失=琥珀 (与悬浮卡中同色, 一眼对应) +function capSquareCls(c: { id: string; usable: boolean }) { + return c.usable ? 'bg-accent' : c.id === 'daily' ? 'bg-danger' : 'bg-warning/80' +} - const tierConfig: Record = { - none: { - desc: '未配置 Key · 仅历史日K', - dotStyle: { background: '#52525b' }, - tagBg: { background: 'rgba(113,113,122,0.15)' }, - labelTextStyle: { color: '#71717a' }, - }, - free: { - desc: '基础日K · 自选实时', - dotStyle: { background: '#71717a' }, - tagBg: { background: 'rgba(113,113,122,0.3)' }, - labelTextStyle: { color: '#a1a1aa' }, - }, - starter: { - desc: '批量同步 · 行情池', - dotStyle: { background: '#3b82f6' }, - tagBg: { background: 'rgba(59,130,246,0.2)' }, - labelTextStyle: { color: '#60a5fa' }, - }, - pro: { - desc: '分钟K · 实时行情 · 盘口', - dotStyle: { background: 'linear-gradient(135deg, #a855f7, #7c3aed)' }, - tagBg: { background: 'linear-gradient(135deg, rgba(168,85,247,0.2), rgba(124,58,237,0.15))' }, - labelTextStyle: { background: 'linear-gradient(135deg, #c084fc, #a855f7)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, - }, - expert: { - desc: 'WebSocket · 财务数据', - dotStyle: { background: 'linear-gradient(135deg, #3b82f6, #a855f7, #f59e0b)' }, - tagBg: { background: 'linear-gradient(135deg, rgba(59,130,246,0.2), rgba(168,85,247,0.2), rgba(245,158,11,0.2))' }, - labelTextStyle: { background: 'linear-gradient(135deg, #60a5fa, #c084fc, #fbbf24)', WebkitBackgroundClip: 'text', backgroundClip: 'text', color: 'transparent' }, - }, +function DataSourceHealthBadge({ matrix }: { matrix: CapabilityMatrix | undefined }) { + const caps = matrix?.capabilities ?? [] + const loading = caps.length === 0 + const usableCount = caps.filter(c => c.usable).length + const down = caps.filter(c => !c.usable) + // 日K是核心能力 (其他一切派生于它): 挂了用危险色; 一般缺项琥珀; 全可用绿 + const level = loading + ? 'loading' + : down.length === 0 ? 'ok' : down.some(c => c.id === 'daily') ? 'danger' : 'warn' + const countCls = level === 'ok' ? 'text-accent/80' + : level === 'danger' ? 'text-danger' + : level === 'warn' ? 'text-warning' + : 'text-muted' + + // 悬浮卡: 侧栏 aside 是 overflow-hidden, 用 fixed 定位逃逸裁剪 (坐标取自徽标实时位置)。 + // 徽标靠近屏幕顶部时居中定位会把卡片上半截推出视口 → 渲染后按实际高度钳制进视口。 + const linkRef = useRef(null) + const popRef = useRef(null) + const closeTimer = useRef(undefined) + const [popPos, setPopPos] = useState<{ left: number; top: number } | null>(null) + const openPop = () => { + window.clearTimeout(closeTimer.current) + const rect = linkRef.current?.getBoundingClientRect() + if (rect) setPopPos({ left: rect.right, top: rect.top + rect.height / 2 }) } - - const t = tierConfig[base] || tierConfig.none - const displayLabel = isNone ? 'None' : (label || 'None') - const descText = isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc + const closePop = () => { + closeTimer.current = window.setTimeout(() => setPopPos(null), 80) + } + useEffect(() => () => window.clearTimeout(closeTimer.current), []) + useLayoutEffect(() => { + if (!popPos || !popRef.current) return + const h = popRef.current.offsetHeight + const margin = 8 + const minCenter = margin + h / 2 + const maxCenter = window.innerHeight - margin - h / 2 + const clamped = Math.min(maxCenter, Math.max(minCenter, popPos.top)) + if (clamped !== popPos.top) setPopPos({ ...popPos, top: clamped }) + }, [popPos]) return ( - - - - - {providerName || '数据源'} - - - {isTickflow && ( - - {displayLabel} + <> + { if (e.key === 'Escape') setPopPos(null) }} + className="group relative flex items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2.5 pr-2 transition-colors duration-150 hover:bg-elevated/70" + > + + + {/* 能力方格 (按注册顺序: 实时/日K/分钟/除权/财务), 与悬浮卡逐格同色对应 */} + + {loading + ? Array.from({ length: 5 }, (_, i) => ( + + )) + : caps.map(c => ( + + ))} + {!loading && ( + + {usableCount}/{caps.length} + + )} + + {popPos && ( +
window.clearTimeout(closeTimer.current)} + onMouseLeave={closePop} + > + +
+ + + 数据源能力 + + + {loading ? '获取中…' : `${usableCount}/${caps.length} 可用`} + +
+
+ {loading ? ( +
正在获取能力路由状态…
+ ) : caps.map(c => ( +
+ + {c.label} + + {c.usable ? ( + <> + {c.effective_display} + + + ) : ( + 未接入 + )} + +
+ ))} +
+ {/* 分时有分钟K功能替身 (intraday_monitor_support 三路可达), 不单独占能力格, 在此备注 */} +
+ 分时信号监控可由分钟 K 数据驱动,不单独设能力格 +
+
+ 点击前往数据源配置 + +
+
+
)} -
+ ) } @@ -275,8 +335,8 @@ function AIConfigBadge({ configured, model }: { configured?: boolean; model?: st export function Layout() { // ===== 共享 hooks (替代内联 useQuery) ===== - const { data: caps } = useCapabilities() const { data: settingsState } = useSettings() + const { data: matrix } = useCapabilityMatrix() const { data: versionData } = useVersion() const { data: prefs } = usePreferences() // 数据源列表 (用于实时行情状态显示当前数据源名称) @@ -446,13 +506,6 @@ export function Layout() { ? '关闭实时行情' : '开启实时行情' - // 当前主数据源 (用于侧边栏数据源状态卡) - const activeProvider = prefs?.daily_data_provider || 'tickflow' - const activeProviderName = activeProvider === 'tickflow' - ? 'TickFlow' - : (dataSources?.custom?.find(s => s.name === activeProvider)?.display_name || activeProvider) - const isCustomActive = activeProvider !== 'tickflow' - // 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒; 后台标签页由 SSE 事件驱动, 不轮询) const alertsTotalQuery = useQuery({ queryKey: ['alerts-total'], @@ -572,15 +625,10 @@ export function Layout() { - {/* 状态卡 — 收起时隐藏 */} - {!navCollapsed && ( -
- + {/* 状态卡 — 收起时隐藏 */} + {!navCollapsed && ( +
+