@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
@@ -20,8 +19,6 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Settings,
|
Settings,
|
||||||
Key,
|
Key,
|
||||||
Database,
|
|
||||||
Loader2,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Tags,
|
Tags,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -30,14 +27,11 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Layers3,
|
Layers3,
|
||||||
Landmark,
|
Landmark,
|
||||||
RadioTower,
|
|
||||||
CheckCircle2,
|
|
||||||
BookOpenCheck,
|
BookOpenCheck,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import { setCurrentTotal as setAlertTotal, useUnreadAlerts } from '@/lib/monitorBadge'
|
|
||||||
|
|
||||||
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
|
// 品牌色 — 只用于 logo / brand 区域,不影响功能语义色
|
||||||
const BRAND = '#8B5CF6'
|
const BRAND = '#8B5CF6'
|
||||||
@@ -50,27 +44,10 @@ const nav = [
|
|||||||
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
{ to: '/concept-analysis', label: '概念分析', icon: Layers3 },
|
||||||
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
|
{ to: '/industry-analysis', label: '行业分析', icon: Landmark },
|
||||||
{ to: '/financials', label: '财务分析', icon: FileText },
|
{ to: '/financials', label: '财务分析', icon: FileText },
|
||||||
{ to: '/monitor', label: '监控中心', icon: RadioTower },
|
|
||||||
{ to: '/review', label: '复盘', icon: BookOpenCheck },
|
{ to: '/review', label: '复盘', icon: BookOpenCheck },
|
||||||
{ to: '/indices', label: '指数', icon: BarChart3 },
|
{ to: '/indices', label: '指数', icon: BarChart3 },
|
||||||
{ to: '/data', label: '数据', icon: Database },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** 监控中心未读徽标 — 仅在非监控页且有未读时显示。 */
|
|
||||||
function MonitorBadge({ active }: { active: boolean }) {
|
|
||||||
const unread = useUnreadAlerts()
|
|
||||||
// 尊重用户设置: 可在菜单设置里关闭数字提示
|
|
||||||
const badgeEnabled = (() => {
|
|
||||||
try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true }
|
|
||||||
})()
|
|
||||||
if (active || unread <= 0 || !badgeEnabled) return null
|
|
||||||
return (
|
|
||||||
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[9px] font-bold text-white animate-pulse">
|
|
||||||
{unread > 99 ? '99+' : unread}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 档位卡片 =====
|
// ===== 档位卡片 =====
|
||||||
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
||||||
const base = label.split(' ')[0].split('+')[0].toLowerCase()
|
const base = label.split(' ')[0].split('+')[0].toLowerCase()
|
||||||
@@ -196,46 +173,8 @@ export function Layout() {
|
|||||||
queryFn: api.analysisMenus,
|
queryFn: api.analysisMenus,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 数据同步状态轮询: 有活跃 job 时「数据」菜单项显示转圈
|
|
||||||
const { data: pipelineJobs } = useQuery({
|
|
||||||
queryKey: QK.pipelineJobs,
|
|
||||||
queryFn: () => api.pipelineJobs(1),
|
|
||||||
refetchInterval: (query) => (query.state.data?.active_id ? 2000 : 15000),
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
})
|
|
||||||
const isDataSyncing = !!pipelineJobs?.active_id
|
|
||||||
|
|
||||||
// 数据同步完成的"瞬时反馈": isDataSyncing 从 true→false 时显示绿色对勾,
|
|
||||||
// 闪烁约 3 秒后自动消失。
|
|
||||||
const [dataSyncJustDone, setDataSyncJustDone] = useState(false)
|
|
||||||
const prevSyncingRef = useRef(false)
|
|
||||||
useEffect(() => {
|
|
||||||
// 仅在"刚结束"(true→false)且非首次挂载时触发
|
|
||||||
if (prevSyncingRef.current && !isDataSyncing) {
|
|
||||||
setDataSyncJustDone(true)
|
|
||||||
const t = setTimeout(() => setDataSyncJustDone(false), 3000)
|
|
||||||
prevSyncingRef.current = isDataSyncing
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
}
|
|
||||||
prevSyncingRef.current = isDataSyncing
|
|
||||||
}, [isDataSyncing])
|
|
||||||
|
|
||||||
const version = versionData?.version
|
const version = versionData?.version
|
||||||
|
|
||||||
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
|
|
||||||
const alertsTotalQuery = useQuery({
|
|
||||||
queryKey: ['alerts-total'],
|
|
||||||
queryFn: () => api.alertsList({ days: 7, limit: 1 }),
|
|
||||||
refetchInterval: 15000,
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
select: (data) => data.total,
|
|
||||||
})
|
|
||||||
// 只在拿到真实总数时同步徽标 (避免 data=undefined 时传 0 重置 lastSeen)
|
|
||||||
const alertsTotal = alertsTotalQuery.data
|
|
||||||
useEffect(() => {
|
|
||||||
if (alertsTotal != null) setAlertTotal(alertsTotal)
|
|
||||||
}, [alertsTotal])
|
|
||||||
|
|
||||||
// 合并内置页面 + 可见的扩展分析菜单
|
// 合并内置页面 + 可见的扩展分析菜单
|
||||||
const analysisNav = (analysisMenus?.items ?? [])
|
const analysisNav = (analysisMenus?.items ?? [])
|
||||||
.filter(m => m.visible)
|
.filter(m => m.visible)
|
||||||
@@ -321,15 +260,6 @@ export function Layout() {
|
|||||||
Beta
|
Beta
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* 数据同步状态: 同步中转圈, 刚完成显示绿色对勾闪烁 3 秒 */}
|
|
||||||
{to === '/data' && isDataSyncing && (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
|
||||||
)}
|
|
||||||
{to === '/data' && !isDataSyncing && dataSyncJustDone && (
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-bull animate-pulse" />
|
|
||||||
)}
|
|
||||||
{/* 监控中心徽标: 仅非监控页且有未读时显示 */}
|
|
||||||
{to === '/monitor' && <MonitorBadge active={isActive} />}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { Eye, EyeOff, ExternalLink, GripVertical, Settings, Bell } from 'lucide-react'
|
import { Eye, EyeOff, ExternalLink, GripVertical, Settings } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { QK } from '@/lib/queryKeys'
|
import { QK } from '@/lib/queryKeys'
|
||||||
@@ -40,18 +40,14 @@ const BUILTIN_PAGES: NavEntry[] = [
|
|||||||
{ id: '/review', label: '复盘', type: 'builtin', visible: true },
|
{ id: '/review', label: '复盘', type: 'builtin', visible: true },
|
||||||
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
|
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
|
||||||
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
|
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
|
||||||
{ id: '/monitor', label: '监控中心', type: 'builtin', visible: true },
|
|
||||||
{ id: '/data', label: '数据', type: 'builtin', visible: true },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// ── Sortable row ──
|
// ── Sortable row ──
|
||||||
|
|
||||||
function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBadge }: {
|
function SortableItem({ entry, hidden, onToggleHidden }: {
|
||||||
entry: NavEntry
|
entry: NavEntry
|
||||||
hidden: boolean
|
hidden: boolean
|
||||||
onToggleHidden: (id: string) => void
|
onToggleHidden: (id: string) => void
|
||||||
badgeEnabled?: boolean
|
|
||||||
onToggleBadge?: (id: string) => void
|
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
@@ -73,7 +69,7 @@ function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBad
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border/70 px-4 py-3 last:border-b-0 ${
|
className={`grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem] items-center border-b border-border/70 px-4 py-3 last:border-b-0 ${
|
||||||
isDragging ? 'bg-elevated rounded-lg shadow-lg' : ''
|
isDragging ? 'bg-elevated rounded-lg shadow-lg' : ''
|
||||||
} ${hidden ? 'opacity-50' : ''}`}
|
} ${hidden ? 'opacity-50' : ''}`}
|
||||||
>
|
>
|
||||||
@@ -132,22 +128,6 @@ function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBad
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 第 6 列: 徽标开关 (仅监控中心) */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
{onToggleBadge && (
|
|
||||||
<button
|
|
||||||
onClick={() => onToggleBadge(entry.id)}
|
|
||||||
className={`rounded p-1 transition-colors ${
|
|
||||||
badgeEnabled
|
|
||||||
? 'text-accent hover:bg-accent/10'
|
|
||||||
: 'text-muted hover:text-accent hover:bg-accent/10'
|
|
||||||
}`}
|
|
||||||
title={badgeEnabled ? '关闭数字提示' : '开启数字提示'}
|
|
||||||
>
|
|
||||||
<Bell className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -246,16 +226,6 @@ export function SettingsMenuSettingsPanel() {
|
|||||||
saveNavHidden.mutate([...next])
|
saveNavHidden.mutate([...next])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监控中心徽标开关 (localStorage)
|
|
||||||
const [badgeEnabled, setBadgeEnabled] = useState(() => {
|
|
||||||
try { return localStorage.getItem('monitor_badge_enabled') !== '0' } catch { return true }
|
|
||||||
})
|
|
||||||
const toggleBadge = (id: string) => {
|
|
||||||
if (id !== '/monitor') return
|
|
||||||
const next = !badgeEnabled
|
|
||||||
setBadgeEnabled(next)
|
|
||||||
try { localStorage.setItem('monitor_badge_enabled', next ? '1' : '0') } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl space-y-6">
|
<div className="max-w-5xl space-y-6">
|
||||||
@@ -268,13 +238,12 @@ export function SettingsMenuSettingsPanel() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-card border border-border bg-surface overflow-hidden">
|
<section className="rounded-card border border-border bg-surface overflow-hidden">
|
||||||
<div className="grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem_3rem] items-center border-b border-border px-4 py-2 text-[11px] text-muted">
|
<div className="grid grid-cols-[2.5rem_1fr_4.5rem_3rem_3rem] items-center border-b border-border px-4 py-2 text-[11px] text-muted">
|
||||||
<div />
|
<div />
|
||||||
<div>菜单</div>
|
<div>菜单</div>
|
||||||
<div>类型</div>
|
<div>类型</div>
|
||||||
<div className="text-center">显示</div>
|
<div className="text-center">显示</div>
|
||||||
<div className="text-center">设置</div>
|
<div className="text-center">设置</div>
|
||||||
<div className="text-center">数字</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -292,8 +261,6 @@ export function SettingsMenuSettingsPanel() {
|
|||||||
entry={entry}
|
entry={entry}
|
||||||
hidden={hiddenSet.has(entry.id)}
|
hidden={hiddenSet.has(entry.id)}
|
||||||
onToggleHidden={toggleHidden}
|
onToggleHidden={toggleHidden}
|
||||||
badgeEnabled={entry.id === '/monitor' ? badgeEnabled : undefined}
|
|
||||||
onToggleBadge={entry.id === '/monitor' ? toggleBadge : undefined}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
|||||||
Reference in New Issue
Block a user