feat: 优化web
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAuthUiStore } from '@/stores/auth-ui-store';
|
||||
|
||||
export function LoginModal() {
|
||||
const open = useAuthUiStore((s) => s.loginModalOpen);
|
||||
const close = useAuthUiStore((s) => s.closeLoginModal);
|
||||
const hint = useAuthUiStore((s) => s.loginHint);
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const router = useRouter();
|
||||
|
||||
const [user, setUser] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [tenant, setTenant] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(user, pass, tenant || undefined);
|
||||
close();
|
||||
setUser('');
|
||||
setPass('');
|
||||
setTenant('');
|
||||
router.refresh();
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-280 bg-black/40" />
|
||||
<Dialog.Content
|
||||
className="fixed left-1/2 top-1/2 z-280 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-lg border border-neutral-200 bg-white p-5 shadow-xl outline-none focus:outline-none"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<Dialog.Title id="relogin-title" className="text-lg font-medium text-neutral-900">
|
||||
重新登录
|
||||
</Dialog.Title>
|
||||
{hint ? (
|
||||
<Dialog.Description className="mt-1 text-sm text-neutral-600">{hint}</Dialog.Description>
|
||||
) : (
|
||||
<Dialog.Description className="sr-only">请输入用户名与密码以继续操作。</Dialog.Description>
|
||||
)}
|
||||
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||
<label className="block text-sm">
|
||||
<span className="text-neutral-600">用户名</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-neutral-600">密码</span>
|
||||
<input
|
||||
type="password"
|
||||
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
|
||||
value={pass}
|
||||
onChange={(e) => setPass(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="text-neutral-600">租户 ID(可空)</span>
|
||||
<input
|
||||
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
|
||||
value={tenant}
|
||||
onChange={(e) => setTenant(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{err ? <p className="text-sm text-red-600">{err}</p> : null}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-neutral-300 px-3 py-1.5 text-sm"
|
||||
onClick={() => {
|
||||
close();
|
||||
setErr(null);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded bg-neutral-900 px-3 py-1.5 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{loading ? '提交中…' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
|
||||
export function ToastHost() {
|
||||
const toasts = useToastStore((s) => s.toasts);
|
||||
const dismiss = useToastStore((s) => s.dismiss);
|
||||
|
||||
if (!toasts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-[200] flex max-w-sm flex-col gap-2"
|
||||
aria-live="polite"
|
||||
>
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`pointer-events-auto rounded-lg border px-3 py-2 text-sm shadow-lg ${
|
||||
t.variant === 'error'
|
||||
? 'border-red-200 bg-red-50 text-red-900'
|
||||
: 'border-neutral-200 bg-white text-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="min-w-0 flex-1">{t.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-neutral-400 hover:text-neutral-700"
|
||||
onClick={() => dismiss(t.id)}
|
||||
aria-label="关闭"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import type { DeptNode } from '@/lib/api/types/dept';
|
||||
|
||||
function DeptNodes({ nodes, depth }: { nodes: DeptNode[]; depth: number }) {
|
||||
return (
|
||||
<ul className={depth === 0 ? 'space-y-1' : 'ml-4 mt-1 space-y-1 border-l border-neutral-200 pl-3'}>
|
||||
{nodes.map((n) => (
|
||||
<li key={n.id} className="text-sm">
|
||||
<span className="text-neutral-800">{n.dept_name}</span>
|
||||
{n.children?.length ? <DeptNodes nodes={n.children} depth={depth + 1} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeptTreeView(props: { tree: DeptNode[] }) {
|
||||
if (!props.tree.length) {
|
||||
return <p className="text-sm text-neutral-500">暂无数据</p>;
|
||||
}
|
||||
return <DeptNodes nodes={props.tree} depth={0} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function IamSectionCard(props: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="box-border flex min-h-0 w-full flex-1 flex-col rounded-lg border border-neutral-200 bg-white p-3 shadow-sm">
|
||||
<h1 className="text-lg font-medium text-neutral-900">{props.title}</h1>
|
||||
{props.description ? (
|
||||
<p className="mt-1 text-sm text-neutral-500">{props.description}</p>
|
||||
) : null}
|
||||
<div className="mt-4 min-h-0 flex-1">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import type { MenuNode } from '@/lib/api/types/menu';
|
||||
|
||||
function MenuNodes({ nodes, depth }: { nodes: MenuNode[]; depth: number }) {
|
||||
return (
|
||||
<ul className={depth === 0 ? 'space-y-1' : 'ml-4 mt-1 space-y-1 border-l border-neutral-200 pl-3'}>
|
||||
{nodes.map((n) => (
|
||||
<li key={n.id} className="text-sm">
|
||||
<span className="font-medium text-neutral-800">{n.menu_name}</span>
|
||||
{n.path ? (
|
||||
<span className="ml-2 font-mono text-xs text-neutral-500">{n.path}</span>
|
||||
) : null}
|
||||
{n.perms ? (
|
||||
<span className="ml-2 text-xs text-neutral-400">[{n.perms}]</span>
|
||||
) : null}
|
||||
{n.children?.length ? <MenuNodes nodes={n.children} depth={depth + 1} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuTreeView(props: { tree: MenuNode[] }) {
|
||||
if (!props.tree.length) {
|
||||
return <p className="text-sm text-neutral-500">暂无数据</p>;
|
||||
}
|
||||
return <MenuNodes nodes={props.tree} depth={0} />;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client';
|
||||
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ClassicCollapsedSidebar } from '@/components/layout/ClassicCollapsedSidebar';
|
||||
import { IconSidebarLayout } from '@/components/layout/IconSidebarLayout';
|
||||
import { NavTooltip } from '@/components/layout/nav-tooltip';
|
||||
import { SidebarNav } from '@/components/layout/SidebarNav';
|
||||
import { TenantSwitcher } from '@/components/layout/TenantSwitcher';
|
||||
import { UserMenu } from '@/components/layout/UserMenu';
|
||||
import { useMenuNavigation } from '@/lib/hooks/use-menu-navigation';
|
||||
import { useNavMenu } from '@/lib/hooks/use-nav-menu';
|
||||
import { redirectToAuthorize } from '@/lib/oauth/browser';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useLayoutStore } from '@/stores/layout-store';
|
||||
|
||||
export function AppChrome({ children }: { children: React.ReactNode }) {
|
||||
const authed = useAuthStore((s) => Boolean(s.accessToken));
|
||||
const sidebarMode = useLayoutStore((s) => s.sidebarMode);
|
||||
const classicNavRailCollapsed = useLayoutStore((s) => s.classicNavRailCollapsed);
|
||||
const toggleClassicNavRail = useLayoutStore((s) => s.toggleClassicNavRail);
|
||||
|
||||
const { items: nav, loading: navLoading, error: navError } = useNavMenu();
|
||||
const onMenuNavigate = useMenuNavigation();
|
||||
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
|
||||
const [showClassicNarrowUi, setShowClassicNarrowUi] = useState(classicNavRailCollapsed);
|
||||
const classicAsideRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(min-width: 768px)');
|
||||
const onChange = () => {
|
||||
if (mq.matches) setMobileNavOpen(false);
|
||||
};
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!classicNavRailCollapsed) setShowClassicNarrowUi(false);
|
||||
}, [classicNavRailCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!classicNavRailCollapsed || showClassicNarrowUi) return;
|
||||
const el = classicAsideRef.current;
|
||||
const finish = () => {
|
||||
if (useLayoutStore.getState().classicNavRailCollapsed) setShowClassicNarrowUi(true);
|
||||
};
|
||||
const onEnd = (e: TransitionEvent) => {
|
||||
if (e.propertyName === 'width') {
|
||||
finish();
|
||||
el?.removeEventListener('transitionend', onEnd);
|
||||
}
|
||||
};
|
||||
el?.addEventListener('transitionend', onEnd);
|
||||
const t = window.setTimeout(finish, 600);
|
||||
return () => {
|
||||
el?.removeEventListener('transitionend', onEnd);
|
||||
window.clearTimeout(t);
|
||||
};
|
||||
}, [classicNavRailCollapsed, showClassicNarrowUi]);
|
||||
|
||||
const classicAsideMotion =
|
||||
'min-w-0 shrink-0 transition-[width] duration-500 ease-in-out motion-reduce:transition-none';
|
||||
|
||||
const asideClass =
|
||||
sidebarMode === 'icon'
|
||||
? 'hidden min-h-0 shrink-0 overflow-hidden border-r border-neutral-200 bg-white md:flex md:flex-col'
|
||||
: `relative hidden h-full min-h-0 flex-col overflow-visible border-r border-neutral-200 bg-white md:flex ${classicAsideMotion}`;
|
||||
|
||||
const classicAsideStyle =
|
||||
sidebarMode !== 'icon' ? { width: classicNavRailCollapsed ? '72px' : '14rem' } : undefined;
|
||||
|
||||
const showExpandedClassicChrome = !classicNavRailCollapsed || !showClassicNarrowUi;
|
||||
|
||||
const sidebarProps = {
|
||||
items: nav,
|
||||
loading: navLoading,
|
||||
error: navError,
|
||||
authed,
|
||||
onMenuNavigate,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh min-h-0 flex-col overflow-hidden">
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 md:gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-2 text-neutral-700 hover:bg-neutral-100 md:hidden"
|
||||
aria-label="打开导航菜单"
|
||||
aria-expanded={mobileNavOpen}
|
||||
onClick={() => setMobileNavOpen(true)}
|
||||
>
|
||||
<span className="text-lg leading-none" aria-hidden>
|
||||
☰
|
||||
</span>
|
||||
</button>
|
||||
<Link href="/dashboard" className="shrink-0 font-semibold text-neutral-800">
|
||||
Smart Admin
|
||||
</Link>
|
||||
{authed ? <TenantSwitcher /> : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
{authed ? (
|
||||
<UserMenu />
|
||||
) : (
|
||||
<>
|
||||
<Link className="text-blue-600 underline" href="/login">
|
||||
账号登录
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-neutral-900 px-2 py-1 text-white"
|
||||
onClick={() => void redirectToAuthorize()}
|
||||
>
|
||||
OAuth 授权
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
<aside ref={classicAsideRef} className={asideClass} style={classicAsideStyle} aria-label="侧栏">
|
||||
{sidebarMode === 'icon' ? (
|
||||
<IconSidebarLayout {...sidebarProps} />
|
||||
) : showExpandedClassicChrome ? (
|
||||
<div className="relative flex h-full min-h-0 flex-1 flex-col overflow-visible">
|
||||
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<SidebarNav {...sidebarProps} />
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 z-50 hidden md:block" aria-hidden>
|
||||
<NavTooltip label="收起侧栏(仅一级菜单)" side="right">
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto absolute left-full top-1/2 z-50 flex h-14 w-3.5 -translate-y-1/2 cursor-pointer items-center justify-center rounded-r-full border border-neutral-200 border-l-0 bg-white text-xs text-neutral-500 shadow-sm transition-transform duration-200 ease-out hover:bg-neutral-50 active:scale-95 motion-reduce:transition-none"
|
||||
aria-expanded={true}
|
||||
aria-label="收起侧栏,仅显示一级菜单"
|
||||
onClick={() => toggleClassicNavRail()}
|
||||
>
|
||||
<span aria-hidden>‹</span>
|
||||
</button>
|
||||
</NavTooltip>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ClassicCollapsedSidebar {...sidebarProps} />
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col bg-neutral-100">{children}</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Root open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-100 bg-black/40 md:hidden" />
|
||||
<Dialog.Content className="fixed left-0 top-0 z-100 flex h-dvh w-56 max-w-[85vw] min-h-0 flex-col border-r border-neutral-200 bg-white p-0 shadow-lg outline-none focus:outline-none md:hidden">
|
||||
<Dialog.Title className="sr-only">导航菜单</Dialog.Title>
|
||||
<Dialog.Description className="sr-only">浏览并选择页面,关闭按钮在右上角。</Dialog.Description>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 z-10 rounded-md p-1.5 text-neutral-600 hover:bg-neutral-100"
|
||||
aria-label="关闭菜单"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
>
|
||||
<span aria-hidden>✕</span>
|
||||
</button>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-10">
|
||||
<SidebarNav {...sidebarProps} onInternalNavigate={() => setMobileNavOpen(false)} />
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { AppChrome } from '@/components/layout/AppChrome';
|
||||
import { RequireAuth } from '@/components/layout/RequireAuth';
|
||||
import { TabStrip } from '@/components/layout/TabStrip';
|
||||
|
||||
/** 登录后带侧栏 + 多标签主工作区布局(dashboard 与 (iam) 等业务页共用) */
|
||||
export function AuthenticatedLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<RequireAuth>
|
||||
<AppChrome>
|
||||
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<TabStrip />
|
||||
{/* 与 TabStrip 间距及距底 12px(Tailwind p-3) */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-3 pt-3 pb-3">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</AppChrome>
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { MenuNode } from '@/lib/api/types/menu';
|
||||
import { NavTooltip } from '@/components/layout/nav-tooltip';
|
||||
import {
|
||||
isLayoutOverviewNode,
|
||||
normalizeHref,
|
||||
isActivePath,
|
||||
visibleChildren,
|
||||
linkClass,
|
||||
} from '@/components/layout/nav-shared';
|
||||
|
||||
function CascadeFolderPanel({
|
||||
title,
|
||||
nodes,
|
||||
pathname,
|
||||
onMenuNavigate,
|
||||
depth = 0,
|
||||
}: {
|
||||
title: string;
|
||||
nodes: MenuNode[];
|
||||
pathname: string;
|
||||
onMenuNavigate?: (path: string, title: string) => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
|
||||
const handleFolderKey = useCallback(
|
||||
(e: React.KeyboardEvent, nodeId: string) => {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setOpenId(nodeId);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpenId(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-56 overflow-visible rounded-md border border-neutral-200 bg-white py-1 shadow-xl">
|
||||
<div className="border-b border-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-900">{title}</div>
|
||||
<div className="relative overflow-visible py-1">
|
||||
{nodes.map((node) => {
|
||||
if (node.menu_type === 3) {
|
||||
return null;
|
||||
}
|
||||
const kids = visibleChildren(node);
|
||||
const hasKids = kids.length > 0;
|
||||
const pad = 8;
|
||||
|
||||
const label = (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{node.icon ? (
|
||||
<span className="shrink-0 text-neutral-500" aria-hidden>
|
||||
{node.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">{node.menu_name}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (hasKids) {
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="relative"
|
||||
onMouseEnter={() => setOpenId(node.id)}
|
||||
>
|
||||
<NavTooltip label={node.menu_name} delayDuration={0}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm text-neutral-800 hover:bg-neutral-100"
|
||||
style={{ paddingLeft: pad }}
|
||||
aria-expanded={openId === node.id}
|
||||
onKeyDown={(e) => handleFolderKey(e, node.id)}
|
||||
>
|
||||
{label}
|
||||
<span className="shrink-0 text-neutral-400" aria-hidden>
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
</NavTooltip>
|
||||
{openId === node.id ? (
|
||||
<div
|
||||
key={node.id}
|
||||
className="cascade-flyout-sub absolute left-full top-0 ml-0 overflow-visible pl-0"
|
||||
style={{ zIndex: 50 + depth }}
|
||||
>
|
||||
<CascadeFolderPanel
|
||||
title={node.menu_name}
|
||||
nodes={kids}
|
||||
pathname={pathname}
|
||||
onMenuNavigate={onMenuNavigate}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const href = normalizeHref(node);
|
||||
const external = Boolean(node.external_link);
|
||||
const active = isLayoutOverviewNode(node)
|
||||
? (pathname.replace(/\/$/, '') || '/') === '/dashboard'
|
||||
: isActivePath(pathname, href);
|
||||
|
||||
if (href === '#') {
|
||||
return (
|
||||
<NavTooltip key={node.id} label={node.menu_name} delayDuration={0}>
|
||||
<div className="rounded-md px-2 py-2 text-sm text-neutral-500" style={{ paddingLeft: pad }}>
|
||||
{label}
|
||||
</div>
|
||||
</NavTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<NavTooltip key={node.id} label={node.menu_name} delayDuration={0}>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass(false)}
|
||||
style={{ paddingLeft: pad }}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
</NavTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavTooltip key={node.id} label={node.menu_name} delayDuration={0}>
|
||||
<Link
|
||||
href={href}
|
||||
className={linkClass(active)}
|
||||
style={{ paddingLeft: pad }}
|
||||
onClick={() => onMenuNavigate?.(href, node.menu_name)}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
</NavTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** L2 起:fixed 根与一级行顶对齐;更深层级在面板内 absolute,与对应行顶对齐;无内部滚动 */
|
||||
export function ClassicCascadeFlyoutPortal({
|
||||
rootNode,
|
||||
anchorRect,
|
||||
pathname,
|
||||
onMenuNavigate,
|
||||
onHoverEnter,
|
||||
onHoverLeave,
|
||||
}: {
|
||||
rootNode: MenuNode;
|
||||
anchorRect: DOMRectReadOnly;
|
||||
pathname: string;
|
||||
onMenuNavigate?: (path: string, title: string) => void;
|
||||
onHoverEnter: () => void;
|
||||
onHoverLeave: () => void;
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!mounted || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodes = visibleChildren(rootNode);
|
||||
if (nodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 左缘与一级导航右缘对齐,不向左叠在窄轨上(不再用负 overlap) */
|
||||
const left = anchorRect.right;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="pointer-events-auto fixed z-50 outline-none"
|
||||
style={{
|
||||
top: anchorRect.top,
|
||||
left,
|
||||
maxWidth: `calc(100vw - ${left}px - 8px)`,
|
||||
}}
|
||||
onMouseEnter={onHoverEnter}
|
||||
onMouseLeave={onHoverLeave}
|
||||
>
|
||||
<div key={rootNode.id} className="cascade-flyout-panel">
|
||||
<CascadeFolderPanel
|
||||
title={rootNode.menu_name}
|
||||
nodes={nodes}
|
||||
pathname={pathname}
|
||||
onMenuNavigate={onMenuNavigate}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { MenuNode } from '@/lib/api/types/menu';
|
||||
import { ClassicCascadeFlyoutPortal } from '@/components/layout/ClassicCascadeFlyout';
|
||||
import { NavTooltip } from '@/components/layout/nav-tooltip';
|
||||
import {
|
||||
NavIconOrLabel,
|
||||
NavStateGuard,
|
||||
normalizeHref,
|
||||
layoutNavRootsFromApi,
|
||||
subtreeContainsActivePath,
|
||||
visibleChildren,
|
||||
} from '@/components/layout/nav-shared';
|
||||
import { useFlyoutState } from '@/lib/hooks/use-flyout-state';
|
||||
import { useMenuNavigation } from '@/lib/hooks/use-menu-navigation';
|
||||
import { useLayoutStore } from '@/stores/layout-store';
|
||||
|
||||
const RAIL_W = 'w-[72px]';
|
||||
|
||||
export function ClassicCollapsedSidebar({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
authed,
|
||||
}: {
|
||||
items: MenuNode[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
authed: boolean;
|
||||
onMenuNavigate?: (path: string, title: string) => void;
|
||||
}) {
|
||||
const pathname = usePathname() ?? '/';
|
||||
const roots = useMemo(() => layoutNavRootsFromApi(items), [items]);
|
||||
const toggleClassicNavRail = useLayoutStore((s) => s.toggleClassicNavRail);
|
||||
const onMenuNavigate = useMenuNavigation();
|
||||
|
||||
const {
|
||||
flyoutRoot,
|
||||
l1AnchorRect,
|
||||
openFlyout,
|
||||
scheduleCloseFlyout,
|
||||
clearCloseTimer,
|
||||
closeFlyoutNow,
|
||||
toggleFlyoutClick,
|
||||
railScrollRef,
|
||||
} = useFlyoutState(pathname);
|
||||
|
||||
const railShell = (inner: React.ReactNode) => (
|
||||
<div className={`relative flex h-full min-h-0 shrink-0 flex-col border-r border-neutral-100 bg-white ${RAIL_W}`}>
|
||||
{inner}
|
||||
<div className="pointer-events-none absolute inset-0 z-[60] hidden md:block" aria-hidden>
|
||||
<NavTooltip label="展开侧栏" side="right">
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto absolute left-full top-1/2 z-[60] flex h-14 w-3.5 -translate-y-1/2 cursor-pointer items-center justify-center rounded-r-full border border-neutral-200 border-l-0 bg-white text-xs text-neutral-500 shadow-sm transition-transform duration-200 ease-out hover:bg-neutral-50 active:scale-95 motion-reduce:transition-none"
|
||||
aria-label="展开侧栏"
|
||||
onClick={() => toggleClassicNavRail()}
|
||||
>
|
||||
<span aria-hidden>›</span>
|
||||
</button>
|
||||
</NavTooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!authed || loading || error) {
|
||||
return (
|
||||
<div className="relative h-full min-h-0 w-[72px] shrink-0 flex-1">
|
||||
{railShell(
|
||||
<NavStateGuard authed={authed} loading={loading} error={error} onMenuNavigate={onMenuNavigate} />,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 w-[72px] shrink-0 flex-1">
|
||||
{railShell(
|
||||
<div ref={railScrollRef} className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
|
||||
<nav className="flex flex-col items-stretch gap-0.5" aria-label="主导航(收起)">
|
||||
{roots.map((node) => {
|
||||
const kids = visibleChildren(node);
|
||||
if (kids.length > 0) {
|
||||
const flyoutHere = flyoutRoot?.id === node.id;
|
||||
const routeHere = subtreeContainsActivePath(node, pathname);
|
||||
const railHighlight = flyoutHere || (routeHere && flyoutRoot === null);
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="relative"
|
||||
data-l1-cascade=""
|
||||
onMouseEnter={(e) => openFlyout(node, e.currentTarget)}
|
||||
onMouseLeave={scheduleCloseFlyout}
|
||||
>
|
||||
<NavTooltip label={node.menu_name}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={flyoutHere}
|
||||
aria-haspopup="true"
|
||||
className={[
|
||||
'flex h-10 w-full items-center justify-center rounded-md text-sm',
|
||||
railHighlight
|
||||
? flyoutHere
|
||||
? 'bg-neutral-200 font-medium text-neutral-900'
|
||||
: 'bg-neutral-100 text-neutral-900'
|
||||
: 'text-neutral-700 hover:bg-neutral-100',
|
||||
].join(' ')}
|
||||
onClick={(e) =>
|
||||
toggleFlyoutClick(
|
||||
node,
|
||||
(e.currentTarget as HTMLElement).closest('[data-l1-cascade]') as HTMLDivElement | null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="truncate px-1 text-center leading-tight">
|
||||
<NavIconOrLabel node={node} />
|
||||
</span>
|
||||
</button>
|
||||
</NavTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const href = normalizeHref(node);
|
||||
const external = Boolean(node.external_link);
|
||||
if (href === '#') {
|
||||
return (
|
||||
<div key={node.id} className="w-full" onMouseEnter={() => closeFlyoutNow()}>
|
||||
<NavTooltip label={node.menu_name} delayDuration={0}>
|
||||
<div className="flex h-10 items-center justify-center rounded-md px-1 text-center text-[11px] text-neutral-400">
|
||||
<NavIconOrLabel node={node} className="text-base" />
|
||||
</div>
|
||||
</NavTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (external) {
|
||||
return (
|
||||
<div key={node.id} className="w-full" onMouseEnter={() => closeFlyoutNow()}>
|
||||
<NavTooltip label={node.menu_name} delayDuration={0}>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-10 items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
<NavIconOrLabel node={node} />
|
||||
</a>
|
||||
</NavTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={node.id} className="w-full" onMouseEnter={() => closeFlyoutNow()}>
|
||||
<NavTooltip label={node.menu_name} delayDuration={0}>
|
||||
<Link
|
||||
href={href}
|
||||
className="flex h-10 items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100"
|
||||
onClick={() => {
|
||||
onMenuNavigate(href, node.menu_name);
|
||||
closeFlyoutNow();
|
||||
}}
|
||||
>
|
||||
<NavIconOrLabel node={node} />
|
||||
</Link>
|
||||
</NavTooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>,
|
||||
)}
|
||||
|
||||
{flyoutRoot && l1AnchorRect && visibleChildren(flyoutRoot).length > 0 ? (
|
||||
<ClassicCascadeFlyoutPortal
|
||||
rootNode={flyoutRoot}
|
||||
anchorRect={l1AnchorRect}
|
||||
pathname={pathname}
|
||||
onMenuNavigate={onMenuNavigate}
|
||||
onHoverEnter={clearCloseTimer}
|
||||
onHoverLeave={scheduleCloseFlyout}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { MenuNode } from '@/lib/api/types/menu';
|
||||
import { NavTooltip } from '@/components/layout/nav-tooltip';
|
||||
import {
|
||||
NavIconOrLabel,
|
||||
NavStateGuard,
|
||||
NavTreeItem,
|
||||
normalizeHref,
|
||||
layoutNavRootsFromApi,
|
||||
subtreeContainsActivePath,
|
||||
visibleChildren,
|
||||
} from '@/components/layout/nav-shared';
|
||||
import { useMenuNavigation } from '@/lib/hooks/use-menu-navigation';
|
||||
|
||||
export function IconSidebarLayout({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
authed,
|
||||
onCloseMobile,
|
||||
}: {
|
||||
items: MenuNode[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
authed: boolean;
|
||||
onMenuNavigate?: (path: string, title: string) => void;
|
||||
onCloseMobile?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname() ?? '/';
|
||||
const roots = useMemo(() => layoutNavRootsFromApi(items), [items]);
|
||||
const onMenuNavigate = useMenuNavigation();
|
||||
|
||||
const findFlyoutRootForPath = useCallback(
|
||||
(path: string): MenuNode | null => {
|
||||
for (const r of roots) {
|
||||
const kids = visibleChildren(r);
|
||||
if (kids.length === 0) continue;
|
||||
if (subtreeContainsActivePath(r, path)) return r;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[roots],
|
||||
);
|
||||
|
||||
const [flyoutRoot, setFlyoutRoot] = useState<MenuNode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const next = findFlyoutRootForPath(pathname);
|
||||
setFlyoutRoot((prev) => {
|
||||
if (next) return next;
|
||||
if (prev && subtreeContainsActivePath(prev, pathname)) return prev;
|
||||
return null;
|
||||
});
|
||||
}, [pathname, findFlyoutRootForPath]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setFlyoutRoot(null);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const toggleFlyout = (root: MenuNode) => {
|
||||
setFlyoutRoot((r) => (r?.id === root.id ? null : root));
|
||||
};
|
||||
|
||||
const railShell = (inner: React.ReactNode) => (
|
||||
<div className="flex h-full min-h-0 w-[72px] shrink-0 flex-col border-r border-neutral-100 bg-white">
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!authed || loading || error) {
|
||||
return railShell(
|
||||
<NavStateGuard authed={authed} loading={loading} error={error} onMenuNavigate={onMenuNavigate} />,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="flex w-[72px] shrink-0 flex-col border-r border-neutral-100 bg-white">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
|
||||
<nav className="flex flex-col items-stretch gap-0.5" aria-label="主导航">
|
||||
{roots.map((node) => {
|
||||
const kids = visibleChildren(node);
|
||||
if (kids.length > 0) {
|
||||
const open = flyoutRoot?.id === node.id;
|
||||
return (
|
||||
<NavTooltip key={node.id} label={node.menu_name}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
className={[
|
||||
'flex h-10 w-full items-center justify-center rounded-md text-sm',
|
||||
open
|
||||
? 'bg-neutral-200 text-neutral-900'
|
||||
: 'text-neutral-700 hover:bg-neutral-100',
|
||||
].join(' ')}
|
||||
onClick={() => toggleFlyout(node)}
|
||||
>
|
||||
<span className="truncate px-1 text-center leading-tight">
|
||||
<NavIconOrLabel node={node} />
|
||||
</span>
|
||||
</button>
|
||||
</NavTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const href = normalizeHref(node);
|
||||
const external = Boolean(node.external_link);
|
||||
if (href === '#') {
|
||||
return (
|
||||
<NavTooltip key={node.id} label={node.menu_name}>
|
||||
<div className="flex h-10 items-center justify-center rounded-md px-1 text-center text-[11px] text-neutral-400">
|
||||
<NavIconOrLabel node={node} className="text-base" />
|
||||
</div>
|
||||
</NavTooltip>
|
||||
);
|
||||
}
|
||||
if (external) {
|
||||
return (
|
||||
<NavTooltip key={node.id} label={node.menu_name}>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-10 items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100"
|
||||
>
|
||||
<NavIconOrLabel node={node} />
|
||||
</a>
|
||||
</NavTooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavTooltip key={node.id} label={node.menu_name}>
|
||||
<Link
|
||||
href={href}
|
||||
className="flex h-10 items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100"
|
||||
onClick={() => {
|
||||
onMenuNavigate(href, node.menu_name);
|
||||
onCloseMobile?.();
|
||||
setFlyoutRoot(null);
|
||||
}}
|
||||
>
|
||||
<NavIconOrLabel node={node} />
|
||||
</Link>
|
||||
</NavTooltip>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{flyoutRoot && visibleChildren(flyoutRoot).length > 0 ? (
|
||||
<div className="flex w-56 shrink-0 flex-col border-r border-neutral-100 bg-white shadow-sm">
|
||||
<div className="sticky top-0 z-10 flex h-10 shrink-0 items-center border-b border-neutral-100 bg-white px-3">
|
||||
<div className="truncate text-sm font-semibold text-neutral-900">{flyoutRoot.menu_name}</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 py-2">
|
||||
{visibleChildren(flyoutRoot).map((c) => (
|
||||
<NavTreeItem
|
||||
key={c.id}
|
||||
node={c}
|
||||
depth={0}
|
||||
pathname={pathname}
|
||||
iconFlyout
|
||||
onMenuNavigate={onMenuNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
export function NavHeader({ onCloseMobile }: { onCloseMobile?: () => void }) {
|
||||
return (
|
||||
<div className="sticky top-0 flex items-center justify-between border-b border-neutral-100 bg-white px-3 py-0.5">
|
||||
{onCloseMobile ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 md:hidden"
|
||||
aria-label="关闭菜单"
|
||||
onClick={onCloseMobile}
|
||||
>
|
||||
<span aria-hidden>✕</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (!accessToken) {
|
||||
const from = pathname || '/dashboard';
|
||||
router.replace(`/login?from=${encodeURIComponent(from)}`);
|
||||
}
|
||||
}, [mounted, accessToken, pathname, router]);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-100 text-sm text-neutral-500">
|
||||
加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-100 text-sm text-neutral-500">
|
||||
跳转登录…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { MenuNode } from '@/lib/api/types/menu';
|
||||
import {
|
||||
isLayoutOverviewNode,
|
||||
NavTreeItem,
|
||||
layoutNavRootsFromApi,
|
||||
} from '@/components/layout/nav-shared';
|
||||
|
||||
export function SidebarNav(props: {
|
||||
items: MenuNode[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
authed: boolean;
|
||||
onInternalNavigate?: () => void;
|
||||
onMenuNavigate?: (path: string, title: string) => void;
|
||||
}) {
|
||||
const pathname = usePathname() || '';
|
||||
|
||||
if (!props.authed) {
|
||||
return <div className="px-3 py-4 text-sm text-neutral-500">登录后加载侧栏菜单</div>;
|
||||
}
|
||||
|
||||
if (props.loading) {
|
||||
return <div className="px-3 py-4 text-sm text-neutral-500">菜单加载中…</div>;
|
||||
}
|
||||
|
||||
if (props.error) {
|
||||
const roots = layoutNavRootsFromApi([]);
|
||||
return (
|
||||
<nav className="space-y-0.5 px-3 pb-4 py-1" aria-label="侧栏导航">
|
||||
{roots.map((n) => (
|
||||
<NavTreeItem
|
||||
key={n.id}
|
||||
node={n}
|
||||
depth={0}
|
||||
pathname={pathname}
|
||||
onInternalNavigate={props.onInternalNavigate}
|
||||
onMenuNavigate={props.onMenuNavigate}
|
||||
/>
|
||||
))}
|
||||
<div className="px-2 py-2 text-sm text-red-600" title={props.error}>
|
||||
菜单加载失败
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const roots = layoutNavRootsFromApi(props.items);
|
||||
const onlyOverview = roots.length === 1 && isLayoutOverviewNode(roots[0]);
|
||||
|
||||
return (
|
||||
<nav className="space-y-0.5 px-3 pb-4 py-1" aria-label="侧栏导航">
|
||||
{roots.map((n) => (
|
||||
<NavTreeItem
|
||||
key={n.id}
|
||||
node={n}
|
||||
depth={0}
|
||||
pathname={pathname}
|
||||
onInternalNavigate={props.onInternalNavigate}
|
||||
onMenuNavigate={props.onMenuNavigate}
|
||||
/>
|
||||
))}
|
||||
{onlyOverview ? <div className="px-2 py-2 text-sm text-neutral-500">暂无业务菜单</div> : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useTabStore } from '@/stores/tab-store';
|
||||
|
||||
/** 多页签条:与路由联动;左右滚动占位(后续可接完整滚动实现)。 */
|
||||
export function TabStrip() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname() ?? '';
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { tabs, activeId, close, activate } = useTabStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname.startsWith('/dashboard')) {
|
||||
return;
|
||||
}
|
||||
useTabStore.getState().syncFromPath(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
const onTabClick = useCallback(
|
||||
(id: string, path: string) => {
|
||||
activate(id);
|
||||
router.push(path);
|
||||
},
|
||||
[activate, router],
|
||||
);
|
||||
|
||||
const scrollBy = (delta: number) => {
|
||||
scrollRef.current?.scrollBy({ left: delta, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-0 border-b border-neutral-200 bg-white px-1 pb-0 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="mb-px shrink-0 cursor-pointer rounded px-1 py-1 text-neutral-500 hover:bg-neutral-100 disabled:opacity-40"
|
||||
aria-label="页签向左滚动"
|
||||
onClick={() => scrollBy(-120)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="tablist"
|
||||
className="flex min-w-0 flex-1 items-end gap-2 overflow-x-auto"
|
||||
>
|
||||
{tabs.map((t) => {
|
||||
const isActive = activeId === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onTabClick(t.id, t.path)}
|
||||
className={`flex shrink-0 cursor-pointer items-end gap-1 rounded-t px-3 pb-1.5 pt-2 text-sm ${
|
||||
isActive
|
||||
? 'bg-neutral-100 font-medium text-neutral-900'
|
||||
: 'text-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<span>{t.title}</span>
|
||||
{!t.pinned ? (
|
||||
<span
|
||||
className="cursor-pointer text-neutral-400 hover:text-neutral-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const next = close(t.id);
|
||||
if (next) router.push(next);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const next = close(t.id);
|
||||
if (next) router.push(next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-px shrink-0 cursor-pointer rounded px-1 py-1 text-neutral-500 hover:bg-neutral-100 disabled:opacity-40"
|
||||
aria-label="页签向右滚动"
|
||||
onClick={() => scrollBy(120)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import * as Select from '@radix-ui/react-select';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { iamTenant } from '@/lib/api/iam';
|
||||
import type { IamTenant } from '@/lib/api/types/tenant';
|
||||
import { useTenantStore } from '@/stores/tenant-store';
|
||||
import { useTabStore } from '@/stores/tab-store';
|
||||
|
||||
/** Radix Select 不允许空字符串作 value,用哨兵表示「默认租户」 */
|
||||
const DEFAULT_TENANT_VALUE = '__tenant_default__';
|
||||
|
||||
export function TenantSwitcher() {
|
||||
const tenantId = useTenantStore((s) => s.tenantId);
|
||||
const setTenantId = useTenantStore((s) => s.setTenantId);
|
||||
const resetTabsForTenantSwitch = useTabStore((s) => s.resetForTenantSwitch);
|
||||
const [rows, setRows] = useState<IamTenant[] | null>(null);
|
||||
const [loadErr, setLoadErr] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await iamTenant.list();
|
||||
setRows(data.items ?? []);
|
||||
setLoadErr(false);
|
||||
} catch {
|
||||
setLoadErr(true);
|
||||
setRows([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const short = tenantId ? (tenantId.length > 10 ? `${tenantId.slice(0, 8)}…` : tenantId) : '默认';
|
||||
|
||||
if (loadErr || !rows || rows.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="hidden max-w-[160px] truncate text-xs text-neutral-500 md:block"
|
||||
title={tenantId ?? ''}
|
||||
>
|
||||
租户 {short}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
<span className="text-xs text-neutral-500" id="tenant-switcher-label">
|
||||
租户
|
||||
</span>
|
||||
<Select.Root
|
||||
value={tenantId ?? DEFAULT_TENANT_VALUE}
|
||||
onValueChange={(v) => {
|
||||
setTenantId(v === DEFAULT_TENANT_VALUE ? null : v);
|
||||
resetTabsForTenantSwitch();
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
className="inline-flex max-w-[140px] items-center justify-between gap-1 truncate rounded border border-neutral-200 bg-white px-1.5 py-0.5 text-xs text-neutral-800 outline-none hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-neutral-400"
|
||||
aria-labelledby="tenant-switcher-label"
|
||||
title={tenantId ?? '默认'}
|
||||
>
|
||||
<Select.Value placeholder="默认" />
|
||||
<Select.Icon aria-hidden>
|
||||
<span className="text-[10px] text-neutral-500">▾</span>
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content
|
||||
className="z-[200] max-h-[min(320px,70vh)] overflow-hidden rounded-md border border-neutral-200 bg-white shadow-lg"
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
align="start"
|
||||
>
|
||||
<Select.Viewport className="p-1">
|
||||
<Select.Item
|
||||
value={DEFAULT_TENANT_VALUE}
|
||||
className="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-xs text-neutral-800 outline-none data-highlighted:bg-neutral-100"
|
||||
>
|
||||
<Select.ItemText>默认</Select.ItemText>
|
||||
</Select.Item>
|
||||
{rows.map((r) => (
|
||||
<Select.Item
|
||||
key={r.id}
|
||||
value={r.id}
|
||||
className="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-xs text-neutral-800 outline-none data-highlighted:bg-neutral-100"
|
||||
>
|
||||
<Select.ItemText className="truncate">
|
||||
{r.tenant_name || r.tenant_code || r.id}
|
||||
</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { avatarInitials, useUserProfile } from '@/lib/hooks/use-user-profile';
|
||||
import { useMenuNavigation } from '@/lib/hooks/use-menu-navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useLayoutStore } from '@/stores/layout-store';
|
||||
import { useTabStore } from '@/stores/tab-store';
|
||||
|
||||
export function UserMenu() {
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const sidebarMode = useLayoutStore((s) => s.sidebarMode);
|
||||
const setSidebarMode = useLayoutStore((s) => s.setSidebarMode);
|
||||
const { profile, userSub, loading, label } = useUserProfile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const onMenuNavigate = useMenuNavigation();
|
||||
const initials = avatarInitials(profile, userSub);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex max-w-[min(100vw-8rem,14rem)] items-center gap-2 rounded-lg border-0 bg-white px-2 py-1.5 text-left text-sm text-neutral-800 outline-none hover:bg-neutral-50 data-[state=open]:bg-neutral-50"
|
||||
aria-label="用户菜单"
|
||||
>
|
||||
{profile?.avatar ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={profile.avatar} alt="" className="h-8 w-8 shrink-0 rounded-full object-cover" />
|
||||
) : (
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-xs font-medium text-neutral-700"
|
||||
aria-hidden
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{label}</span>
|
||||
<span className="shrink-0 text-neutral-400" aria-hidden>
|
||||
{open ? '▴' : '▾'}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="z-50 w-60 rounded-lg border border-neutral-200 bg-white py-1 shadow-lg outline-none"
|
||||
sideOffset={4}
|
||||
align="end"
|
||||
collisionPadding={8}
|
||||
>
|
||||
<div className="border-b border-neutral-100 px-3 py-2">
|
||||
<p className="truncate text-sm font-medium text-neutral-900">{label}</p>
|
||||
{profile?.email ? (
|
||||
<p className="mt-0.5 truncate text-xs text-neutral-500">{profile.email}</p>
|
||||
) : null}
|
||||
{!profile?.email && userSub ? (
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-neutral-400" title={userSub}>
|
||||
ID {userSub}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="block cursor-pointer px-3 py-2 text-sm text-neutral-800 outline-none data-highlighted:bg-neutral-50"
|
||||
onClick={() => onMenuNavigate('/dashboard', '概览')}
|
||||
>
|
||||
工作台
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
href="/dashboard/account"
|
||||
className="block cursor-pointer px-3 py-2 text-sm text-neutral-800 outline-none data-highlighted:bg-neutral-50"
|
||||
onClick={() => onMenuNavigate('/dashboard/account', '个人中心')}
|
||||
>
|
||||
个人中心
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Separator className="my-1 h-px bg-neutral-100" />
|
||||
|
||||
<div className="border-b border-neutral-100 px-3 py-2">
|
||||
<p className="mb-1 text-xs text-neutral-500">侧栏布局</p>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 rounded border px-2 py-1 text-xs ${
|
||||
sidebarMode === 'classic'
|
||||
? 'border-neutral-900 bg-neutral-900 text-white'
|
||||
: 'border-neutral-200 bg-white text-neutral-700'
|
||||
}`}
|
||||
onClick={() => setSidebarMode('classic')}
|
||||
>
|
||||
经典
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 rounded border px-2 py-1 text-xs ${
|
||||
sidebarMode === 'icon'
|
||||
? 'border-neutral-900 bg-neutral-900 text-white'
|
||||
: 'border-neutral-200 bg-white text-neutral-700'
|
||||
}`}
|
||||
onClick={() => setSidebarMode('icon')}
|
||||
>
|
||||
图标
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Item
|
||||
className="cursor-pointer px-3 py-2 text-left text-sm text-red-600 outline-none data-highlighted:bg-red-50"
|
||||
onSelect={() => {
|
||||
void logout();
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
'use client';
|
||||
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import Link from 'next/link';
|
||||
import type { MenuNode } from '@/lib/api/types/menu';
|
||||
import { NavTooltip } from '@/components/layout/nav-tooltip';
|
||||
|
||||
/** 布局固定「概览」,与 IAM nav 拼成同一棵树(不入库) */
|
||||
export const LAYOUT_OVERVIEW_MENU_ID = '__layout_overview__';
|
||||
|
||||
export function isLayoutOverviewNode(node: MenuNode): boolean {
|
||||
return node.id === LAYOUT_OVERVIEW_MENU_ID;
|
||||
}
|
||||
|
||||
export const layoutOverviewMenuNode: MenuNode = {
|
||||
id: LAYOUT_OVERVIEW_MENU_ID,
|
||||
parent_id: '',
|
||||
menu_name: '概览',
|
||||
menu_type: 2,
|
||||
path: '/dashboard',
|
||||
icon: '📊',
|
||||
};
|
||||
|
||||
/** 将布局「概览」与 IAM nav 合并为侧栏顶层列表,便于组好完整结构再一次性渲染 */
|
||||
export function layoutNavRootsFromApi(items: MenuNode[]): MenuNode[] {
|
||||
return [layoutOverviewMenuNode, ...items.filter((n) => n.menu_type !== 3)];
|
||||
}
|
||||
|
||||
export function normalizeHref(node: MenuNode): string {
|
||||
if (node.external_link) {
|
||||
return node.external_link;
|
||||
}
|
||||
const p = node.path?.trim();
|
||||
if (!p) {
|
||||
return '#';
|
||||
}
|
||||
if (p.startsWith('http://') || p.startsWith('https://')) {
|
||||
return p;
|
||||
}
|
||||
return p.startsWith('/') ? p : `/${p}`;
|
||||
}
|
||||
|
||||
export function isActivePath(pathname: string, href: string): boolean {
|
||||
if (!href || href === '#' || href.startsWith('http://') || href.startsWith('https://')) {
|
||||
return false;
|
||||
}
|
||||
if (pathname === href) {
|
||||
return true;
|
||||
}
|
||||
if (href !== '/' && pathname.startsWith(`${href}/`)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function linkClass(active: boolean): string {
|
||||
return [
|
||||
'block rounded-md px-2 py-2 text-sm',
|
||||
active
|
||||
? 'bg-neutral-200 font-medium text-neutral-900'
|
||||
: 'text-neutral-800 hover:bg-neutral-100',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** 经典树:目录可展开,叶子为链接(用于经典侧栏与图标模式右侧浮层) */
|
||||
export function NavTreeItem({
|
||||
node,
|
||||
depth,
|
||||
pathname,
|
||||
onInternalNavigate,
|
||||
onMenuNavigate,
|
||||
/** 图标模式浮层:二级及以下项之间额外增加 2px(space-y-0.5) */
|
||||
iconFlyout = false,
|
||||
}: {
|
||||
node: MenuNode;
|
||||
depth: number;
|
||||
pathname: string;
|
||||
onInternalNavigate?: () => void;
|
||||
onMenuNavigate?: (path: string, title: string) => void;
|
||||
iconFlyout?: boolean;
|
||||
}) {
|
||||
if (node.menu_type === 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = (node.children ?? []).filter((c) => c.menu_type !== 3);
|
||||
const hasChildren = children.length > 0;
|
||||
const pad = 10 + depth * 10;
|
||||
|
||||
const label = (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{node.icon ? (
|
||||
<span className="shrink-0 text-neutral-500" aria-hidden>
|
||||
{node.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">{node.menu_name}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<Collapsible.Root defaultOpen className="min-w-0 select-none">
|
||||
<Collapsible.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm text-neutral-800 hover:bg-neutral-100"
|
||||
style={{ paddingLeft: pad }}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
className="shrink-0 text-neutral-400 transition-transform duration-200 ease-out group-aria-expanded:rotate-0 motion-reduce:transition-none -rotate-90"
|
||||
aria-hidden
|
||||
>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content
|
||||
className="grid overflow-hidden transition-[grid-template-rows] duration-200 ease-in-out motion-reduce:transition-none data-[state=open]:grid-rows-[1fr] data-[state=closed]:grid-rows-[0fr]"
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<div
|
||||
className={`ml-3 border-l border-neutral-200 pl-1${iconFlyout ? ' space-y-0.5' : ''}`}
|
||||
>
|
||||
{children.map((c) => (
|
||||
<NavTreeItem
|
||||
key={c.id}
|
||||
node={c}
|
||||
depth={depth + 1}
|
||||
pathname={pathname}
|
||||
iconFlyout={iconFlyout}
|
||||
onInternalNavigate={onInternalNavigate}
|
||||
onMenuNavigate={onMenuNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const href = normalizeHref(node);
|
||||
const external = Boolean(node.external_link);
|
||||
const active = isLayoutOverviewNode(node)
|
||||
? (pathname.replace(/\/$/, '') || '/') === '/dashboard'
|
||||
: isActivePath(pathname, href);
|
||||
|
||||
if (href === '#') {
|
||||
return (
|
||||
<div className="rounded-md px-2 py-2 text-sm text-neutral-500" style={{ paddingLeft: pad }}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (external) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass(false)}
|
||||
style={{ paddingLeft: pad }}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={linkClass(active)}
|
||||
style={{ paddingLeft: pad }}
|
||||
onClick={() => {
|
||||
onMenuNavigate?.(href, node.menu_name);
|
||||
onInternalNavigate?.();
|
||||
}}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function visibleChildren(node: MenuNode): MenuNode[] {
|
||||
return (node.children ?? []).filter((c) => c.menu_type !== 3);
|
||||
}
|
||||
|
||||
/** 当前路径是否落在该节点子树内(用于图标模式自动展开浮层) */
|
||||
export function subtreeContainsActivePath(node: MenuNode, pathname: string): boolean {
|
||||
if (isLayoutOverviewNode(node)) {
|
||||
const p = pathname.replace(/\/$/, '') || '/';
|
||||
return p === '/dashboard';
|
||||
}
|
||||
const href = normalizeHref(node);
|
||||
if (!node.external_link && href !== '#' && isActivePath(pathname, href)) {
|
||||
return true;
|
||||
}
|
||||
for (const c of visibleChildren(node)) {
|
||||
if (subtreeContainsActivePath(c, pathname)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 窄轨图标/文字切换:有 icon 显示 icon,否则显示菜单名 */
|
||||
export function NavIconOrLabel({ node, className }: { node: MenuNode; className?: string }) {
|
||||
if (node.icon) {
|
||||
return (
|
||||
<span className={className ?? 'block text-base'} aria-hidden>
|
||||
{node.icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="line-clamp-2 px-1 text-center text-[11px] font-medium">{node.menu_name}</span>;
|
||||
}
|
||||
|
||||
/** 侧栏未认证/加载中/出错三态守卫 */
|
||||
export function NavStateGuard({
|
||||
authed,
|
||||
loading,
|
||||
error,
|
||||
onMenuNavigate,
|
||||
children,
|
||||
}: {
|
||||
authed: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onMenuNavigate: (path: string, title: string) => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
if (!authed) {
|
||||
return <div className="px-2 py-4 text-center text-[11px] text-neutral-500">登录后加载菜单</div>;
|
||||
}
|
||||
if (loading) {
|
||||
return <div className="px-2 py-4 text-center text-[11px] text-neutral-500">菜单加载中…</div>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-1 py-1">
|
||||
<NavTooltip label="概览">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex h-10 w-full items-center justify-center rounded-md text-neutral-700 hover:bg-neutral-100"
|
||||
onClick={() => onMenuNavigate('/dashboard', '概览')}
|
||||
>
|
||||
<span className="text-base" aria-hidden>
|
||||
📊
|
||||
</span>
|
||||
<span className="sr-only">概览</span>
|
||||
</Link>
|
||||
</NavTooltip>
|
||||
<NavTooltip label={error}>
|
||||
<p className="mt-2 cursor-default px-1 text-center text-[11px] text-red-600">菜单失败</p>
|
||||
</NavTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
type NavTooltipProps = {
|
||||
label: string;
|
||||
children: ReactElement;
|
||||
/** 窄轨叶子项可设 0,与原先即时提示接近 */
|
||||
delayDuration?: number;
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
};
|
||||
|
||||
/**
|
||||
* 侧栏图标/收起态等:替代原生 `title`,统一键盘可达与延时(受全局 TooltipProvider 影响可被 Root 覆盖)。
|
||||
*/
|
||||
export function NavTooltip({ label, children, delayDuration = 300, side = 'right' }: NavTooltipProps) {
|
||||
if (!label.trim()) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Root delayDuration={delayDuration}>
|
||||
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side={side}
|
||||
sideOffset={6}
|
||||
className="z-[300] max-w-[min(280px,calc(100vw-16px))] rounded-md bg-neutral-900 px-2.5 py-1.5 text-xs font-medium text-white shadow-lg"
|
||||
>
|
||||
{label}
|
||||
<Tooltip.Arrow className="fill-neutral-900" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { TooltipProvider } from '@radix-ui/react-tooltip';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { LoginModal } from '@/components/auth/LoginModal';
|
||||
import { ToastHost } from '@/components/feedback/ToastHost';
|
||||
import { ErrorBoundary } from '@/components/providers/ErrorBoundary';
|
||||
import { registerAuthEvents } from '@/lib/notify/auth-events';
|
||||
import { subscribeRemoteLogout } from '@/lib/sync/logout-broadcast';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAuthUiStore } from '@/stores/auth-ui-store';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname() ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
registerAuthEvents({
|
||||
on401: (msg) => {
|
||||
useAuthUiStore.getState().openLoginModal(msg);
|
||||
},
|
||||
on403: (msg) => {
|
||||
useToastStore.getState().show(msg || '禁止访问', 'error');
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeRemoteLogout(() => {
|
||||
useAuthStore.getState().setTokens(null, null);
|
||||
useToastStore.getState().show('已在其他标签退出登录', 'info');
|
||||
if (pathname.startsWith('/dashboard')) {
|
||||
router.replace(`/login?from=${encodeURIComponent(pathname)}`);
|
||||
}
|
||||
});
|
||||
}, [pathname, router]);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
<ToastHost />
|
||||
<LoginModal />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { Component, type ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
};
|
||||
|
||||
type State = {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error('[ErrorBoundary]', error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-[200px] flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<p className="text-sm text-neutral-600">页面出现异常</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-neutral-900 px-3 py-1.5 text-sm text-white hover:bg-neutral-800"
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user