复制 local 代码到 serve

This commit is contained in:
2026-07-04 16:59:25 +08:00
parent cee04c1b46
commit 0474e5fb46
302 changed files with 78290 additions and 0 deletions
@@ -0,0 +1,311 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Eye, EyeOff, ExternalLink, GripVertical, Settings, Bell } from 'lucide-react'
import { Link } from 'react-router-dom'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { usePreferences } from '@/lib/useSharedQueries'
interface NavEntry {
id: string
label: string
type: 'builtin' | 'analysis'
visible: boolean
}
const BUILTIN_PAGES: NavEntry[] = [
{ id: '/', label: '看板', type: 'builtin', visible: true },
{ id: '/watchlist', label: '自选', type: 'builtin', visible: true },
{ id: '/screener', label: '策略', type: 'builtin', visible: true },
{ id: '/backtest', label: '回测', type: 'builtin', visible: true },
{ id: '/limit-ladder', label: '连板梯队', type: 'builtin', visible: true },
{ id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
{ id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
{ id: '/stock-analysis', label: '个股分析', type: 'builtin', visible: true },
{ id: '/review', label: '复盘', type: 'builtin', visible: true },
{ id: '/financials', label: '财务分析', type: 'builtin', visible: true },
{ id: '/indices', label: '指数', type: 'builtin', visible: true },
{ id: '/trading', label: '交易', type: 'builtin', visible: true },
{ id: '/monitor', label: '监控中心', type: 'builtin', visible: true },
{ id: '/data', label: '数据', type: 'builtin', visible: true },
]
// ── Sortable row ──
function SortableItem({ entry, hidden, onToggleHidden, badgeEnabled, onToggleBadge }: {
entry: NavEntry
hidden: boolean
onToggleHidden: (id: string) => void
badgeEnabled?: boolean
onToggleBadge?: (id: string) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: entry.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
zIndex: isDragging ? 10 : undefined,
}
return (
<div
ref={setNodeRef}
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 ${
isDragging ? 'bg-elevated rounded-lg shadow-lg' : ''
} ${hidden ? 'opacity-50' : ''}`}
>
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted hover:text-foreground transition-colors"
>
<GripVertical className="h-4 w-4" />
</div>
<div className="min-w-0 flex items-center gap-2">
<span className={`truncate text-sm font-medium ${!hidden ? 'text-foreground' : 'text-muted line-through'}`}>
{entry.label}
</span>
{hidden && (
<span className="rounded bg-elevated px-1.5 py-0.5 text-[10px] text-muted shrink-0"></span>
)}
<span className="truncate text-[11px] text-muted font-mono">{entry.id}</span>
</div>
<div>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] ${
entry.type === 'analysis' ? 'bg-accent/10 text-accent' : 'bg-elevated text-muted'
}`}>
{entry.type === 'builtin' ? '内置' : '扩展'}
</span>
</div>
<div className="flex justify-center">
<button
onClick={() => onToggleHidden(entry.id)}
className={`rounded p-1 transition-colors ${
hidden
? 'text-muted hover:text-accent hover:bg-accent/10'
: 'text-accent hover:bg-accent/10'
}`}
title={hidden ? '显示' : '隐藏'}
>
{hidden ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div className="flex justify-center">
{entry.type === 'builtin' ? (
<Link
to={entry.id}
className="rounded p-1 text-muted hover:text-accent hover:bg-accent/10 transition-colors"
title="打开页面"
>
<ExternalLink className="h-3.5 w-3.5" />
</Link>
) : (
<Link
to={`/settings?tab=ext-pages`}
className="rounded p-1 text-muted hover:text-accent hover:bg-accent/10 transition-colors"
title="编辑扩展页面"
>
<Settings className="h-3.5 w-3.5" />
</Link>
)}
</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>
)
}
// ── Main panel ──
export function SettingsMenuSettingsPanel() {
const qc = useQueryClient()
const { data: prefs } = usePreferences()
const menus = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus })
const analysisEntries: NavEntry[] = (menus.data?.items ?? []).map(m => ({
id: m.id,
label: m.label,
type: 'analysis' as const,
visible: m.visible,
}))
const allEntries = useMemo(() => {
const saved = prefs?.nav_order ?? []
const entryMap = new Map<string, NavEntry>()
for (const e of BUILTIN_PAGES) entryMap.set(e.id, e)
for (const e of analysisEntries) entryMap.set(e.id, e)
if (saved.length === 0) return [...BUILTIN_PAGES, ...analysisEntries]
const ordered: NavEntry[] = []
const seen = new Set<string>()
for (const id of saved) {
const entry = entryMap.get(id)
if (entry) {
ordered.push(entry)
seen.add(id)
}
}
for (const e of [...BUILTIN_PAGES, ...analysisEntries]) {
if (!seen.has(e.id)) ordered.push(e)
}
return ordered
}, [prefs?.nav_order, analysisEntries])
const hiddenSet = useMemo(() => new Set(prefs?.nav_hidden ?? []), [prefs?.nav_hidden])
// Local order state for optimistic drag updates
const [localOrder, setLocalOrder] = useState<string[] | null>(null)
const orderedEntries = useMemo(() => {
const order = localOrder ?? prefs?.nav_order ?? []
if (!order.length) return allEntries
const byId = new Map(allEntries.map(e => [e.id, e]))
const result: NavEntry[] = []
const seen = new Set<string>()
for (const id of order) {
const e = byId.get(id)
if (e) { result.push(e); seen.add(id) }
}
for (const e of allEntries) {
if (!seen.has(e.id)) result.push(e)
}
return result
}, [localOrder, prefs?.nav_order, allEntries])
const saveNavOrder = useMutation({
mutationFn: (order: string[]) => api.saveNavOrder(order),
onSuccess: () => {
setLocalOrder(null)
qc.invalidateQueries({ queryKey: QK.preferences })
},
})
const saveNavHidden = useMutation({
mutationFn: (hidden: string[]) => api.saveNavHidden(hidden),
onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
})
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const ids = orderedEntries.map(e => e.id)
const oldIdx = ids.indexOf(active.id as string)
const newIdx = ids.indexOf(over.id as string)
const reordered = arrayMove(ids, oldIdx, newIdx)
setLocalOrder(reordered)
saveNavOrder.mutate(reordered)
}
const toggleHidden = (id: string) => {
const next = new Set(hiddenSet)
if (next.has(id)) next.delete(id)
else next.add(id)
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 (
<div className="max-w-5xl space-y-6">
<section className="rounded-2xl border border-border bg-surface p-6 bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.12),transparent_38%)]">
<div className="text-[11px] uppercase tracking-[0.2em] text-accent/80"></div>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground"></h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-secondary">
</p>
</section>
<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 />
<div></div>
<div></div>
<div className="text-center"></div>
<div className="text-center"></div>
<div className="text-center"></div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={orderedEntries.map(e => e.id)}
strategy={verticalListSortingStrategy}
>
{orderedEntries.map((entry) => (
<SortableItem
key={entry.id}
entry={entry}
hidden={hiddenSet.has(entry.id)}
onToggleHidden={toggleHidden}
badgeEnabled={entry.id === '/monitor' ? badgeEnabled : undefined}
onToggleBadge={entry.id === '/monitor' ? toggleBadge : undefined}
/>
))}
</SortableContext>
</DndContext>
{menus.isLoading && (
<div className="px-5 py-10 text-center text-sm text-muted">...</div>
)}
</section>
</div>
)
}