feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Go HTTP 根地址(无尾部斜杠,不含 /api/v1)
NEXT_PUBLIC_API_ORIGIN=http://127.0.0.1:8000
# OAuth 公开客户端(与 Go 种子 client 一致)
NEXT_PUBLIC_OAUTH_CLIENT_ID=spa
# 换 token 时须与登录请求 redirect_uri 一致(本机开发)
NEXT_PUBLIC_OAUTH_REDIRECT_URI=http://localhost:3000/oauth/callback
+42
View File
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+6
View File
@@ -0,0 +1,6 @@
.next
node_modules
out
build
package-lock.json
tsconfig.tsbuildinfo
+6
View File
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100
}
+19
View File
@@ -0,0 +1,19 @@
# WebNext.js
对接 [`../docs/auth-api.md`](../docs/auth-api.md) 与 Go `/api/v1`
## 本地开发
```bash
cp .env.example .env.local
npm install
npm run dev
```
`NEXT_PUBLIC_API_ORIGIN` 指向 Go 监听地址(默认开发 `http://127.0.0.1:8000`)。
## 目录约定
- `lib/api/` — HTTP 客户端与各领域 API
- `stores/` — Zustand 状态
- `lib/env.ts` — 公共环境变量读取
@@ -0,0 +1,22 @@
'use client';
import { IamSectionCard } from '@/components/iam/IamSectionCard';
import { MenuTreeView } from '@/components/iam/MenuTreeView';
import { useApi } from '@/lib/hooks/use-api';
import { iamMenu } from '@/lib/api/iam';
import type { MenuNode } from '@/lib/api/types/menu';
export default function IamResourcePage() {
const { data, loading, error } = useApi<MenuNode[]>(() => iamMenu.tree());
return (
<IamSectionCard
title="资源(菜单)"
description="全局菜单树,对接 GET /api/v1/iam/menu/tree;与侧栏 nav 数据源一致(nav 会按角色过滤)。"
>
{loading ? <p className="text-sm text-neutral-500"></p> : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{!loading && !error && data ? <MenuTreeView tree={data} /> : null}
</IamSectionCard>
);
}
@@ -0,0 +1,46 @@
'use client';
import { IamSectionCard } from '@/components/iam/IamSectionCard';
import { useApi } from '@/lib/hooks/use-api';
import { iamTenant } from '@/lib/api/iam';
import type { IamTenant } from '@/lib/api/types/tenant';
export default function IamTenantPage() {
const { data, loading, error } = useApi(() => iamTenant.list({ page: '1', page_size: '100' }));
const rows = data?.items ?? [];
return (
<IamSectionCard
title="租户管理"
description="对接 GET /api/v1/iam/tenant/list,后续可接新增/编辑/删除。"
>
{loading ? <p className="text-sm text-neutral-500"></p> : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{!loading && !error ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[480px] border-collapse text-sm">
<thead>
<tr className="border-b border-neutral-200 text-left text-neutral-600">
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2">ID</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-neutral-100">
<td className="py-2 pr-2">{r.tenant_name ?? '—'}</td>
<td className="py-2 pr-2 font-mono text-xs">{r.tenant_code ?? '—'}</td>
<td className="py-2 pr-2">{r.status ?? '—'}</td>
<td className="py-2 font-mono text-xs text-neutral-500">{r.id}</td>
</tr>
))}
</tbody>
</table>
{!rows.length ? <p className="mt-2 text-neutral-500"></p> : null}
</div>
) : null}
</IamSectionCard>
);
}
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { DeptTreeView } from '@/components/iam/DeptTreeView';
import { IamSectionCard } from '@/components/iam/IamSectionCard';
import { useApi } from '@/lib/hooks/use-api';
import { iamDept } from '@/lib/api/iam';
import type { DeptNode } from '@/lib/api/types/dept';
export default function IamDeptPage() {
const { data, loading, error } = useApi<DeptNode[]>(() => iamDept.tree());
return (
<IamSectionCard title="部门管理" description="对接 GET /api/v1/iam/dept/tree。">
{loading ? <p className="text-sm text-neutral-500"></p> : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{!loading && !error && data ? <DeptTreeView tree={data} /> : null}
</IamSectionCard>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { IamSectionCard } from '@/components/iam/IamSectionCard';
import { useApi } from '@/lib/hooks/use-api';
import { iamRole } from '@/lib/api/iam';
import type { IamRole } from '@/lib/api/types/role';
export default function IamRolePage() {
const { data, loading, error } = useApi(() => iamRole.list({ page: '1', page_size: '50' }));
const rows = data?.items ?? [];
return (
<IamSectionCard title="角色管理" description="对接 GET /api/v1/iam/role/list。">
{loading ? <p className="text-sm text-neutral-500"></p> : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{!loading && !error ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[480px] border-collapse text-sm">
<thead>
<tr className="border-b border-neutral-200 text-left text-neutral-600">
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2">ID</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-neutral-100">
<td className="py-2 pr-2">{r.role_name ?? '—'}</td>
<td className="py-2 pr-2 font-mono text-xs">{r.role_code ?? '—'}</td>
<td className="py-2 pr-2">{r.data_scope ?? '—'}</td>
<td className="py-2 font-mono text-xs text-neutral-500">{r.id}</td>
</tr>
))}
</tbody>
</table>
{!rows.length ? <p className="mt-2 text-neutral-500"></p> : null}
</div>
) : null}
</IamSectionCard>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { IamSectionCard } from '@/components/iam/IamSectionCard';
import { useApi } from '@/lib/hooks/use-api';
import { iamUser } from '@/lib/api/iam';
import type { IamUser } from '@/lib/api/types/user';
export default function IamUserPage() {
const { data, loading, error } = useApi(() => iamUser.list({ page: '1', page_size: '20' }));
const rows = data?.items ?? [];
return (
<IamSectionCard title="用户管理" description="对接 GET /api/v1/iam/user/list。">
{loading ? <p className="text-sm text-neutral-500"></p> : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{!loading && !error ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] border-collapse text-sm">
<thead>
<tr className="border-b border-neutral-200 text-left text-neutral-600">
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2">ID</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-neutral-100">
<td className="py-2 pr-2">{r.user_name ?? '—'}</td>
<td className="py-2 pr-2">{r.real_name ?? '—'}</td>
<td className="py-2 pr-2">{r.status ?? '—'}</td>
<td className="py-2 font-mono text-xs text-neutral-500">{r.id}</td>
</tr>
))}
</tbody>
</table>
{!rows.length ? <p className="mt-2 text-neutral-500"></p> : null}
</div>
) : null}
</IamSectionCard>
);
}
@@ -0,0 +1,8 @@
export default function AccountPage() {
return (
<div className="w-full rounded-lg bg-white p-4 text-neutral-700 shadow-sm">
<h1 className="text-lg font-medium"></h1>
<p className="mt-2 text-sm text-neutral-500"> IAM</p>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function DashboardPage() {
return (
<div className="w-full rounded-lg bg-white p-4 text-neutral-700 shadow-sm">
<h1 className="text-lg font-medium"></h1>
<p className="mt-2 text-sm text-neutral-500"> Tabs + </p>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
/**
* 单一布局包裹 /dashboard 与 /user 等路由,避免 Tab 在二者间切换时卸载布局、
* 导致 AppChrome 重挂载并重复请求侧栏菜单。
*/
export default function MainLayout({ children }: { children: React.ReactNode }) {
return <AuthenticatedLayout>{children}</AuthenticatedLayout>;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+55
View File
@@ -0,0 +1,55 @@
@import 'tailwindcss';
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
/* 收起态侧栏:一级悬停展开的二级浮层入场(略慢、缓出更柔和) */
@keyframes cascade-flyout-in {
from {
opacity: 0;
transform: translateX(-6px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.cascade-flyout-panel {
animation: cascade-flyout-in 0.4s cubic-bezier(0.33, 1, 0.68, 1) both;
}
@keyframes cascade-flyout-sub-in {
from {
opacity: 0;
transform: translateX(-4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.cascade-flyout-sub {
animation: cascade-flyout-sub-in 0.32s cubic-bezier(0.33, 1, 0.68, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.cascade-flyout-panel,
.cascade-flyout-sub {
animation: none;
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { AppProviders } from '@/components/providers/AppProviders';
import './globals.css';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Smart Go',
description: 'Smart Go 管理平台',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<AppProviders>{children}</AppProviders>
</body>
</html>
);
}
+128
View File
@@ -0,0 +1,128 @@
'use client';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { safeReturnPath } from '@/lib/navigation/safe-return';
import { useAuthStore } from '@/stores/auth-store';
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const accessToken = useAuthStore((s) => s.accessToken);
const login = useAuthStore((s) => s.login);
const [mounted, setMounted] = useState(false);
const [user, setUser] = useState('');
const [pass, setPass] = useState('');
const [tenant, setTenant] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted || !accessToken) {
return;
}
const next = safeReturnPath(searchParams.get('from'), '/dashboard');
router.replace(next);
}, [mounted, accessToken, router, searchParams]);
if (!mounted) {
return (
<main className="flex min-h-screen items-center justify-center text-sm text-neutral-500">
</main>
);
}
if (accessToken) {
return (
<main className="flex min-h-screen items-center justify-center text-sm text-neutral-500">
</main>
);
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setErr(null);
setLoading(true);
try {
await login(user, pass, tenant || undefined);
const next = safeReturnPath(searchParams.get('from'), '/dashboard');
router.replace(next);
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-6">
<form
onSubmit={onSubmit}
className="w-full max-w-sm rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
>
<h1 className="text-lg font-medium"></h1>
<label className="mt-4 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="mt-3 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="mt-3 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="mt-3 text-sm text-red-600">{err}</p> : null}
<button
type="submit"
disabled={loading}
className="mt-4 w-full rounded bg-neutral-900 py-2 text-white disabled:opacity-50"
>
{loading ? '提交中…' : '登录'}
</button>
</form>
<Link href="/" className="text-sm text-blue-600">
</Link>
</main>
);
}
export default function LoginPage() {
return (
<Suspense
fallback={
<main className="flex min-h-screen items-center justify-center text-sm text-neutral-500">
</main>
}
>
<LoginForm />
</Suspense>
);
}
+67
View File
@@ -0,0 +1,67 @@
'use client';
import { exchangeCodeForTokens } from '@/lib/api/auth';
import { getOAuthClientId, getOAuthRedirectUri } from '@/lib/env';
import { takeStoredPkceVerifier } from '@/lib/oauth/browser';
import { useAuthStore } from '@/stores/auth-store';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
function CallbackInner() {
const sp = useSearchParams();
const router = useRouter();
const [msg, setMsg] = useState<string>('处理中…');
useEffect(() => {
const code = sp.get('code');
const err = sp.get('error');
if (err) {
setMsg(sp.get('error_description') || err);
return;
}
if (!code) {
setMsg('缺少授权码');
return;
}
const verifier = takeStoredPkceVerifier();
if (!verifier) {
setMsg('缺少 PKCE verifier,请从授权入口重新登录');
return;
}
(async () => {
try {
const pair = await exchangeCodeForTokens({
code,
codeVerifier: verifier,
clientId: getOAuthClientId(),
redirectUri: getOAuthRedirectUri(),
});
useAuthStore.getState().setTokens(pair.accessToken, pair.refreshToken);
setMsg('登录成功,正在跳转…');
router.replace('/dashboard');
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e));
}
})();
}, [sp, router]);
return (
<main className="flex min-h-screen items-center justify-center p-6">
<p className="text-neutral-600">{msg}</p>
</main>
);
}
export default function OAuthCallbackPage() {
return (
<Suspense
fallback={
<main className="flex min-h-screen items-center justify-center">
<p></p>
</main>
}
>
<CallbackInner />
</Suspense>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Link from 'next/link';
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-8">
<h1 className="text-2xl font-semibold text-neutral-800">Smart Go · Web</h1>
<p className="max-w-md text-center text-neutral-600">
Go `NEXT_PUBLIC_API_ORIGIN` + PKCE token OAuth
</p>
<div className="flex flex-wrap justify-center gap-3">
<Link href="/login" className="rounded-lg bg-neutral-900 px-5 py-2 text-white">
</Link>
<Link
href="/dashboard"
className="rounded-lg border border-neutral-300 px-5 py-2 text-neutral-800"
>
</Link>
</div>
</main>
);
}
+118
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+23
View File
@@ -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} />;
}
+15
View File
@@ -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>
);
}
+29
View File
@@ -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} />;
}
+179
View File
@@ -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 间距及距底 12pxTailwind 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>
);
}
+180
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}
+44
View File
@@ -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}</>;
}
+68
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+264
View File
@@ -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;
}
+37
View File
@@ -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>
);
}
+47
View File
@@ -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;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { dirname } from 'path';
import { fileURLToPath } from 'url';
import { FlatCompat } from '@eslint/eslintrc';
import eslintConfigPrettier from 'eslint-config-prettier';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
eslintConfigPrettier,
{
ignores: ['node_modules/**', '.next/**', 'out/**', 'build/**', 'next-env.d.ts'],
},
];
export default eslintConfig;
+184
View File
@@ -0,0 +1,184 @@
import { getOAuthClientId, getOAuthRedirectUri, getPublicApiOrigin } from '@/lib/env';
import { createPKCEPair } from '@/lib/oauth/pkce';
import type { ApiEnvelope } from '@/lib/api/types';
export type TokenPair = {
accessToken: string;
refreshToken: string;
expiresIn: number;
};
/** 用户名密码 + PKCE 换 authorization_code,再 POST /oauth/token。 */
export async function loginWithPassword(params: {
userName: string;
password: string;
tenantId?: string;
state?: string;
}): Promise<TokenPair> {
const origin = getPublicApiOrigin();
const clientId = getOAuthClientId();
const redirectUri = getOAuthRedirectUri();
const { verifier, challenge } = await createPKCEPair();
const res = await fetch(`${origin}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
tenant_id: params.tenantId ?? '',
user_name: params.userName,
password: params.password,
client_id: clientId,
redirect_uri: redirectUri,
code_challenge: challenge,
code_challenge_method: 'S256',
state: params.state ?? '',
scope: 'openid',
}),
});
const json = (await res.json()) as ApiEnvelope<{
authorization_code?: string;
state?: string;
}>;
if (res.status === 401) {
throw new Error(json.msg || '用户名或密码错误');
}
if (res.status === 403) {
throw new Error(json.msg || '无权限');
}
if (!res.ok) {
throw new Error(json.msg || `HTTP ${res.status}`);
}
if (json.code !== 200 || !json.data?.authorization_code) {
throw new Error(json.msg || '登录失败');
}
return exchangeCodeForTokens({
code: json.data.authorization_code,
codeVerifier: verifier,
clientId,
redirectUri,
});
}
export async function exchangeCodeForTokens(params: {
code: string;
codeVerifier: string;
clientId: string;
redirectUri: string;
}): Promise<TokenPair> {
const origin = getPublicApiOrigin();
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirectUri,
client_id: params.clientId,
code_verifier: params.codeVerifier,
});
const res = await fetch(`${origin}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
error?: string;
error_description?: string;
};
if (!res.ok || !data.access_token) {
throw new Error(data.error_description || data.error || '换取 access_token 失败');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? '',
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 0,
};
}
export async function refreshAccessToken(refreshToken: string): Promise<TokenPair> {
const origin = getPublicApiOrigin();
const clientId = getOAuthClientId();
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
});
const res = await fetch(`${origin}/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
error?: string;
error_description?: string;
};
if (!res.ok || !data.access_token) {
throw new Error(data.error_description || data.error || '刷新 token 失败');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token ?? refreshToken,
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 0,
};
}
export async function logoutRequest(): Promise<void> {
const origin = getPublicApiOrigin();
const res = await fetch(`${origin}/api/v1/auth/logout`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { msg?: string };
throw new Error(j.msg || '登出失败');
}
}
/** RFC 7662 风格,用于解析 opaque access_token 的 `sub`(用户 id */
export async function introspectAccessToken(accessToken: string): Promise<{
active: boolean;
sub?: string;
scope?: string;
exp?: number;
client_id?: string;
}> {
const origin = getPublicApiOrigin();
const body = new URLSearchParams({
token: accessToken,
token_type_hint: 'access_token',
});
const res = await fetch(`${origin}/oauth/introspect`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = (await res.json()) as {
active?: boolean;
sub?: string;
scope?: string;
exp?: number;
client_id?: string;
};
return {
active: Boolean(data.active),
sub: data.sub,
scope: data.scope,
exp: data.exp,
client_id: data.client_id,
};
}
+111
View File
@@ -0,0 +1,111 @@
import { getPublicApiOrigin } from '@/lib/env';
import { emit401Unauthorized, emit403Forbidden } from '@/lib/notify/auth-events';
import { refreshTokensShared } from '@/lib/api/refresh-flight';
import { ApiError, type ApiEnvelope } from '@/lib/api/types';
type GetTokens = () => { accessToken: string | null; refreshToken: string | null };
type SetTokens = (access: string | null, refresh: string | null) => void;
let tokenBridge: { get: GetTokens; set: SetTokens } | null = null;
/** 由 auth store 在客户端挂载时注册,避免循环依赖。 */
export function registerTokenBridge(get: GetTokens, set: SetTokens) {
tokenBridge = { get, set };
}
let tenantIdGetter: () => string | null = () => null;
/** 由 tenant store 注册,请求 `/api/v1` 时附加 `X-Tenant-ID`(可选)。 */
export function registerTenantHeaderBridge(get: () => string | null) {
tenantIdGetter = get;
}
function authHeader(): HeadersInit {
const t = tokenBridge?.get().accessToken;
return t ? { Authorization: `Bearer ${t}` } : {};
}
function tenantHeader(): HeadersInit {
const tid = tenantIdGetter?.();
return tid ? { 'X-Tenant-ID': tid } : {};
}
/** 业务 JSONHTTP 200 时解析 envelope401 时共享 refresh 后重试一次(登录请求勿走此逻辑)。 */
export async function apiJson<T>(
path: string,
init: RequestInit & { skipRefreshRetry?: boolean } = {}
): Promise<T> {
const { skipRefreshRetry, ...req } = init;
const url = `${getPublicApiOrigin()}${path.startsWith('/') ? path : `/${path}`}`;
const method = (req.method || 'GET').toUpperCase();
const hasBody = req.body != null && req.body !== '';
const jsonHeaders: HeadersInit =
method !== 'GET' && method !== 'HEAD' && hasBody ? { 'Content-Type': 'application/json' } : {};
const baseHeaders = {
...jsonHeaders,
...authHeader(),
...tenantHeader(),
...(req.headers || {}),
};
const first = await fetch(url, {
...req,
headers: baseHeaders,
credentials: 'include',
});
if (first.status === 401 && !skipRefreshRetry && tokenBridge) {
const ok = await refreshTokensShared(
() => tokenBridge!.get().refreshToken,
(a, r) => tokenBridge!.set(a, r)
);
if (ok) {
const second = await fetch(url, {
...req,
headers: {
...jsonHeaders,
...authHeader(),
...tenantHeader(),
...(req.headers || {}),
},
credentials: 'include',
});
return handleEnvelope<T>(second);
}
}
return handleEnvelope<T>(first);
}
async function handleEnvelope<T>(res: Response): Promise<T> {
const text = await res.text();
let body: unknown = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
throw new ApiError('响应不是合法 JSON', res.status, text);
}
if (res.status === 401) {
const msg = (body as { msg?: string })?.msg || '未授权';
emit401Unauthorized(msg);
throw new ApiError(msg, 401, body);
}
if (res.status === 403) {
const msg = (body as { msg?: string })?.msg || '禁止访问';
emit403Forbidden(msg);
throw new ApiError(msg, 403, body);
}
if (!res.ok) {
throw new ApiError(`HTTP ${res.status}`, res.status, body);
}
const env = body as ApiEnvelope<T>;
if (typeof env?.code !== 'number') {
return body as T;
}
if (env.code !== 200) {
throw new ApiError(env.msg || '业务失败', res.status, env);
}
return env.data as T;
}
+99
View File
@@ -0,0 +1,99 @@
import { apiJson } from '@/lib/api/client';
import { API_V1 } from '@/lib/api/paths';
import type { DeptNode, IamDept } from '@/lib/api/types/dept';
import type { MenuNode } from '@/lib/api/types/menu';
import type { IamRole } from '@/lib/api/types/role';
import type { IamTenant } from '@/lib/api/types/tenant';
import type { IamUser } from '@/lib/api/types/user';
const B = `${API_V1}/iam`;
/** 列表响应信封 */
type ListEnvelope<T> = { items: T[]; total: number };
/** 租户 */
export const iamTenant = {
create: (body: Partial<IamTenant>) =>
apiJson<IamTenant>(`${B}/tenant/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<IamTenant>) =>
apiJson<IamTenant>(`${B}/tenant/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/tenant/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (id: string) => apiJson<IamTenant>(`${B}/tenant/get/${encodeURIComponent(id)}`),
list: (query?: Record<string, string>) => {
const q = query ? new URLSearchParams(query).toString() : 'page=1&page_size=200';
return apiJson<ListEnvelope<IamTenant>>(`${B}/tenant/list?${q}`);
},
};
/** 部门 */
export const iamDept = {
create: (body: Partial<IamDept>) =>
apiJson<IamDept>(`${B}/dept/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<IamDept>) =>
apiJson<IamDept>(`${B}/dept/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/dept/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (id: string) => apiJson<IamDept>(`${B}/dept/get/${encodeURIComponent(id)}`),
tree: () => apiJson<DeptNode[]>(`${B}/dept/tree`),
};
/** 角色 */
export const iamRole = {
create: (body: Partial<IamRole>) =>
apiJson<IamRole>(`${B}/role/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<IamRole>) =>
apiJson<IamRole>(`${B}/role/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/role/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (id: string) => apiJson<IamRole>(`${B}/role/get/${encodeURIComponent(id)}`),
list: (query?: Record<string, string>) => {
const q = query ? new URLSearchParams(query).toString() : 'page=1&page_size=50';
return apiJson<ListEnvelope<IamRole>>(`${B}/role/list?${q}`);
},
};
/** 用户 */
export const iamUser = {
create: (body: Partial<IamUser>) =>
apiJson<IamUser>(`${B}/user/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<IamUser>) =>
apiJson<IamUser>(`${B}/user/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/user/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (id: string) => apiJson<IamUser>(`${B}/user/get/${encodeURIComponent(id)}`),
list: (query?: Record<string, string>) => {
const q = query ? new URLSearchParams(query).toString() : 'page=1&page_size=20';
return apiJson<ListEnvelope<IamUser>>(`${B}/user/list?${q}`);
},
};
/** 菜单 */
export const iamMenu = {
create: (body: Partial<MenuNode>) =>
apiJson<MenuNode>(`${B}/menu/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<MenuNode>) =>
apiJson<MenuNode>(`${B}/menu/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/menu/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (id: string) => apiJson<MenuNode>(`${B}/menu/get/${encodeURIComponent(id)}`),
tree: () => apiJson<MenuNode[]>(`${B}/menu/tree`),
/** 当前用户可见导航树(需 Bearer,后端从 token 解析 user_id */
nav: () => apiJson<MenuNode[]>(`${B}/menu/nav`),
perms: () => apiJson<{ perms: string[] }>(`${B}/menu/perms`),
};
+2
View File
@@ -0,0 +1,2 @@
/** 与 Go `apiGroup` 前缀一致 */
export const API_V1 = '/api/v1';
+33
View File
@@ -0,0 +1,33 @@
import { refreshAccessToken } from '@/lib/api/auth';
let inFlight: Promise<boolean> | null = null;
/**
* 并发 401 时共享同一次 refresh,避免重复 POST /oauth/token。
* refresh 失败会清空 token(由调用方传入的 setTokens 执行)。
*/
export function refreshTokensShared(
getRefreshToken: () => string | null,
setTokens: (access: string | null, refresh: string | null) => void
): Promise<boolean> {
if (inFlight) {
return inFlight;
}
inFlight = (async () => {
try {
const rt = getRefreshToken();
if (!rt) {
return false;
}
const pair = await refreshAccessToken(rt);
setTokens(pair.accessToken, pair.refreshToken);
return true;
} catch {
setTokens(null, null);
return false;
} finally {
inFlight = null;
}
})();
return inFlight;
}
+27
View File
@@ -0,0 +1,27 @@
import { apiJson } from '@/lib/api/client';
import { API_V1 } from '@/lib/api/paths';
import type { SystemParam } from '@/lib/api/types/system-param';
const B = `${API_V1}/system/param`;
type ListEnvelope<T> = { items: T[]; total: number };
export const systemParam = {
create: (body: Partial<SystemParam>) =>
apiJson<SystemParam>(`${B}/create`, { method: 'POST', body: JSON.stringify(body) }),
update: (id: string, body: Partial<SystemParam>) =>
apiJson<SystemParam>(`${B}/update/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteBatch: (ids: string[]) =>
apiJson<void>(`${B}/delete-batch`, { method: 'DELETE', body: JSON.stringify(ids) }),
get: (query: Record<string, string>) => {
const q = new URLSearchParams(query).toString();
return apiJson<SystemParam>(`${B}/get?${q}`);
},
list: (query?: Record<string, string>) => {
const q = query ? new URLSearchParams(query).toString() : '';
return apiJson<ListEnvelope<SystemParam>>(q ? `${B}/list?${q}` : `${B}/list`);
},
};
+17
View File
@@ -0,0 +1,17 @@
/** 与 Go `/api/v1` JSON 信封一致(见 docs/auth-api.md */
export type ApiEnvelope<T> = {
code: number;
msg: string;
data: T | null;
};
export class ApiError extends Error {
constructor(
message: string,
public readonly httpStatus: number,
public readonly body?: unknown
) {
super(message);
this.name = 'ApiError';
}
}
+18
View File
@@ -0,0 +1,18 @@
/** 与 Go `iam/entity.Dept` JSON 对齐 */
export type IamDept = {
id: string;
tenant_id: string;
parent_id: string;
dept_name: string;
dept_path: string;
leader_id?: string | null;
sort_order: number;
status: number;
created_at: string;
updated_at: string;
};
/** 与 Go `iam/service.DeptNode` JSON 对齐(递归树) */
export type DeptNode = IamDept & {
children?: DeptNode[];
};
+15
View File
@@ -0,0 +1,15 @@
/** 与 Go `iam/service.MenuNode` / `entity.Menu` JSON 对齐 */
export type MenuNode = {
id: string;
parent_id: string;
menu_name: string;
menu_type: number;
perms?: string;
path: string;
component?: string;
icon?: string;
sort_order?: number;
is_visible?: boolean;
external_link?: string;
children?: MenuNode[];
};
+13
View File
@@ -0,0 +1,13 @@
/** 与 Go `iam/entity.Role` JSON 对齐 */
export type IamRole = {
id: string;
tenant_id: string;
role_code: string;
role_name: string;
data_scope: number;
description: string;
is_builtin: boolean;
status: number;
created_at: string;
updated_at: string;
};
+13
View File
@@ -0,0 +1,13 @@
/** 与 Go `system/entity.SystemParam` JSON 对齐 */
export type SystemParam = {
id: string;
param_key: string;
param_value: string;
param_type: string;
param_group: string;
param_desc: string;
creator_id: string;
create_time?: string | null;
last_updater_id: string;
update_time?: string | null;
};
+11
View File
@@ -0,0 +1,11 @@
/** 与 Go `iam/entity.Tenant` JSON 对齐 */
export type IamTenant = {
id: string;
tenant_code: string;
tenant_name: string;
admin_user_id?: string | null;
status: number;
expire_time?: string | null;
created_at: string;
updated_at: string;
};
+13
View File
@@ -0,0 +1,13 @@
/** 与后端 iam_user JSON 对齐(展示用字段子集) */
export type IamUser = {
id: string;
tenant_id: string;
dept_id?: string | null;
user_name: string;
real_name?: string;
phone?: string;
email?: string;
avatar?: string;
gender?: number;
status?: number;
};
+30
View File
@@ -0,0 +1,30 @@
/** 浏览器可读的 API 根(scheme + host + port),不含路径。生产环境务必配置 NEXT_PUBLIC_API_ORIGIN。 */
export function getPublicApiOrigin(): string {
const o =
process.env.NEXT_PUBLIC_API_ORIGIN ||
(process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:8000' : '');
if (!o) {
throw new Error('NEXT_PUBLIC_API_ORIGIN is not set');
}
return o.replace(/\/$/, '');
}
export function getOAuthClientId(): string {
const id =
process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID ||
(process.env.NODE_ENV === 'development' ? 'spa' : '');
if (!id) {
throw new Error('NEXT_PUBLIC_OAUTH_CLIENT_ID is not set');
}
return id;
}
export function getOAuthRedirectUri(): string {
const u =
process.env.NEXT_PUBLIC_OAUTH_REDIRECT_URI ||
(process.env.NODE_ENV === 'development' ? 'http://localhost:3000/oauth/callback' : '');
if (!u) {
throw new Error('NEXT_PUBLIC_OAUTH_REDIRECT_URI is not set');
}
return u;
}
+60
View File
@@ -0,0 +1,60 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
type UseApiState<T> = {
data: T | null;
loading: boolean;
error: string | null;
};
/**
* 通用数据获取 hook,封装 loading / error / data 状态 + AbortController 取消。
*
* @param fetcher 返回 Promise 的数据获取函数,接收 AbortSignal
* @param deps 依赖数组,变化时重新请求
*/
export function useApi<T>(
fetcher: (signal?: AbortSignal) => Promise<T>,
deps: unknown[] = [],
): UseApiState<T> & { refetch: () => void } {
const [state, setState] = useState<UseApiState<T>>({
data: null,
loading: true,
error: null,
});
const abortRef = useRef<AbortController | null>(null);
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
const load = useCallback(() => {
abortRef.current?.abort();
const ac = new AbortController();
abortRef.current = ac;
setState((s) => ({ ...s, loading: true, error: null }));
fetcherRef
.current(ac.signal)
.then((data) => {
if (!ac.signal.aborted) {
setState({ data, loading: false, error: null });
}
})
.catch((e: unknown) => {
if (!ac.signal.aborted) {
setState({ data: null, loading: false, error: e instanceof Error ? e.message : String(e) });
}
});
}, deps);
useEffect(() => {
load();
return () => {
abortRef.current?.abort();
};
}, [load]);
return { ...state, refetch: load };
}
+129
View File
@@ -0,0 +1,129 @@
'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { MenuNode } from '@/lib/api/types/menu';
const HOVER_LEAVE_MS = 280;
type FlyoutState = {
flyoutRoot: MenuNode | null;
l1AnchorRect: DOMRect | null;
openFlyout: (node: MenuNode, anchorEl: HTMLDivElement) => void;
scheduleCloseFlyout: () => void;
clearCloseTimer: () => void;
closeFlyoutNow: () => void;
toggleFlyoutClick: (node: MenuNode, wrapper: HTMLDivElement | null) => void;
l1AnchorElRef: React.RefObject<HTMLDivElement | null>;
railScrollRef: React.RefObject<HTMLDivElement | null>;
};
/** 从 ClassicCollapsedSidebar 提取的 flyout 状态管理逻辑 */
export function useFlyoutState(pathname: string): FlyoutState {
const [flyoutRoot, setFlyoutRoot] = useState<MenuNode | null>(null);
const [l1AnchorRect, setL1AnchorRect] = useState<DOMRect | null>(null);
const l1AnchorElRef = useRef<HTMLDivElement | null>(null);
const railScrollRef = useRef<HTMLDivElement | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearCloseTimer = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const closeFlyout = useCallback(() => {
setFlyoutRoot(null);
setL1AnchorRect(null);
l1AnchorElRef.current = null;
}, []);
const scheduleCloseFlyout = useCallback(() => {
clearCloseTimer();
closeTimerRef.current = setTimeout(() => {
closeFlyout();
closeTimerRef.current = null;
}, HOVER_LEAVE_MS);
}, [clearCloseTimer, closeFlyout]);
const closeFlyoutNow = useCallback(() => {
clearCloseTimer();
closeFlyout();
}, [clearCloseTimer, closeFlyout]);
const syncL1Anchor = useCallback(() => {
const el = l1AnchorElRef.current;
if (el) setL1AnchorRect(el.getBoundingClientRect());
}, []);
useLayoutEffect(() => {
if (flyoutRoot) syncL1Anchor();
}, [flyoutRoot, syncL1Anchor]);
useEffect(() => {
if (!flyoutRoot) return;
const onResize = () => syncL1Anchor();
window.addEventListener('resize', onResize);
const rs = railScrollRef.current;
rs?.addEventListener('scroll', onResize, { passive: true });
return () => {
window.removeEventListener('resize', onResize);
rs?.removeEventListener('scroll', onResize);
};
}, [flyoutRoot, syncL1Anchor]);
useEffect(() => {
closeFlyout();
}, [pathname, closeFlyout]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeFlyout();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [closeFlyout]);
useEffect(() => clearCloseTimer, [clearCloseTimer]);
const openFlyout = useCallback(
(node: MenuNode, anchorEl: HTMLDivElement) => {
clearCloseTimer();
l1AnchorElRef.current = anchorEl;
setL1AnchorRect(anchorEl.getBoundingClientRect());
setFlyoutRoot(node);
},
[clearCloseTimer],
);
const toggleFlyoutClick = useCallback(
(node: MenuNode, wrapper: HTMLDivElement | null) => {
setFlyoutRoot((prev) => {
if (prev?.id === node.id) {
setL1AnchorRect(null);
l1AnchorElRef.current = null;
return null;
}
if (wrapper) {
l1AnchorElRef.current = wrapper;
setL1AnchorRect(wrapper.getBoundingClientRect());
return node;
}
return prev;
});
},
[],
);
return {
flyoutRoot,
l1AnchorRect,
openFlyout,
scheduleCloseFlyout,
clearCloseTimer,
closeFlyoutNow,
toggleFlyoutClick,
l1AnchorElRef,
railScrollRef,
};
}
+14
View File
@@ -0,0 +1,14 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTabStore } from '@/stores/tab-store';
/** 站内导航 + 多标签联动,替代 onMenuNavigate prop drilling */
export function useMenuNavigation() {
const router = useRouter();
return (path: string, title: string) => {
useTabStore.getState().openOrActivate({ path, title });
router.push(path);
};
}
+70
View File
@@ -0,0 +1,70 @@
'use client';
import { useEffect, useState } from 'react';
import { iamMenu } from '@/lib/api/iam';
import type { MenuNode } from '@/lib/api/types/menu';
import { useAuthStore } from '@/stores/auth-store';
import { useLayoutStore } from '@/stores/layout-store';
import { useTenantStore } from '@/stores/tenant-store';
type NavMenuState = {
items: MenuNode[];
loading: boolean;
error: string | null;
authed: boolean;
permissions: string[];
};
/** 从 AppChrome 提取的导航菜单数据获取逻辑 */
export function useNavMenu(): NavMenuState {
const accessToken = useAuthStore((s) => s.accessToken);
const authed = Boolean(accessToken);
const tenantId = useTenantStore((s) => s.tenantId);
const setPerms = useLayoutStore((s) => s.setPerms);
const [items, setItems] = useState<MenuNode[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [permissions, setPermissions] = useState<string[]>([]);
useEffect(() => {
if (!accessToken) {
setItems([]);
setError(null);
setPerms([]);
setPermissions([]);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
Promise.all([
iamMenu.nav().catch(() => [] as MenuNode[]),
iamMenu.perms().catch(() => ({ perms: [] as string[] })),
])
.then(([tree, pr]) => {
if (cancelled) return;
const navItems = Array.isArray(tree) ? tree : [];
const perms = Array.isArray(pr?.perms) ? pr.perms : [];
setItems(navItems);
setPerms(perms);
setPermissions(perms);
})
.catch((e: unknown) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : String(e));
setItems([]);
setPerms([]);
setPermissions([]);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [accessToken, tenantId, setPerms]);
return { items, loading, error, authed, permissions };
}
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { useEffect, useState } from 'react';
import { introspectAccessToken } from '@/lib/api/auth';
import { iamUser } from '@/lib/api/iam';
import type { IamUser } from '@/lib/api/types/user';
import { useAuthStore } from '@/stores/auth-store';
import { useTenantStore } from '@/stores/tenant-store';
type UserProfileState = {
profile: IamUser | null;
userSub: string | null;
loading: boolean;
label: string;
};
function displayLabel(profile: IamUser | null, userSub: string | null, loading: boolean): string {
if (loading) return '加载中…';
if (profile) {
const n = profile.real_name?.trim() || profile.user_name?.trim();
if (n) return n;
}
if (userSub) return userSub.length > 12 ? `${userSub.slice(0, 10)}` : userSub;
return '用户';
}
/** 从 UserMenu 提取的用户资料获取逻辑 */
export function useUserProfile(): UserProfileState {
const accessToken = useAuthStore((s) => s.accessToken);
const [profile, setProfile] = useState<IamUser | null>(null);
const [userSub, setUserSub] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!accessToken) {
setProfile(null);
setUserSub(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setProfile(null);
setUserSub(null);
(async () => {
try {
const intro = await introspectAccessToken(accessToken);
if (cancelled) return;
if (!intro.active || !intro.sub) {
setUserSub(null);
setLoading(false);
return;
}
setUserSub(intro.sub);
try {
const u = await iamUser.get(intro.sub);
if (!cancelled) {
setProfile(u);
useTenantStore.getState().hydrateFromUserTenant(u.tenant_id);
}
} catch {
if (!cancelled) setProfile(null);
}
} catch {
if (!cancelled) {
setUserSub(null);
setProfile(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [accessToken]);
return {
profile,
userSub,
loading,
label: displayLabel(profile, userSub, loading),
};
}
export function avatarInitials(profile: IamUser | null, userSub: string | null): string {
const name = profile?.real_name?.trim() || profile?.user_name?.trim();
if (name) {
const arr = [...name];
if (arr.length >= 2) return (arr[0] + arr[1]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
if (userSub) return userSub.replace(/-/g, '').slice(0, 2).toUpperCase() || '?';
return '?';
}
+9
View File
@@ -0,0 +1,9 @@
/** 解析 Go 侧常见列表响应 `{ items, total, ... }` */
export function extractListItems<T>(data: unknown): T[] {
if (!data || typeof data !== 'object') {
return [];
}
const o = data as { items?: unknown; Items?: unknown };
const arr = o.items ?? o.Items;
return Array.isArray(arr) ? (arr as T[]) : [];
}
+11
View File
@@ -0,0 +1,11 @@
/** 仅允许站内相对路径,防止开放重定向 */
export function safeReturnPath(from: string | null | undefined, fallback = '/dashboard'): string {
if (from == null || typeof from !== 'string') {
return fallback;
}
const t = from.trim();
if (!t.startsWith('/') || t.startsWith('//')) {
return fallback;
}
return t;
}
+26
View File
@@ -0,0 +1,26 @@
/** 供 `apiJson` 调用,避免直接依赖 React;由 AppProviders 注册实现。 */
type AuthEventsImpl = {
on401: (msg: string) => void;
on403: (msg: string) => void;
};
let impl: Partial<AuthEventsImpl> = {};
let last401At = 0;
export function registerAuthEvents(next: Partial<AuthEventsImpl>) {
impl = next;
}
/** 同一秒内合并多次 401,避免并发请求重复弹窗 */
export function emit401Unauthorized(msg: string) {
const now = Date.now();
if (now - last401At < 800) {
return;
}
last401At = now;
impl.on401?.(msg);
}
export function emit403Forbidden(msg: string) {
impl.on403?.(msg);
}
+39
View File
@@ -0,0 +1,39 @@
'use client';
import { getOAuthClientId, getOAuthRedirectUri, getPublicApiOrigin } from '@/lib/env';
import { createPKCEPair } from '@/lib/oauth/pkce';
const VERIFIER_KEY = 'smart_oauth_pkce_verifier';
/** 跳转浏览器授权页(与 GET /oauth/authorize 一致);PKCE verifier 写入 sessionStorage,回调页取出换 token。 */
export async function redirectToAuthorize(): Promise<void> {
const { verifier, challenge } = await createPKCEPair();
if (typeof window !== 'undefined') {
sessionStorage.setItem(VERIFIER_KEY, verifier);
}
const params = new URLSearchParams({
response_type: 'code',
client_id: getOAuthClientId(),
redirect_uri: getOAuthRedirectUri(),
scope: 'openid',
code_challenge: challenge,
code_challenge_method: 'S256',
state:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: String(Date.now()),
});
const url = `${getPublicApiOrigin()}/oauth/authorize?${params.toString()}`;
window.location.href = url;
}
export function takeStoredPkceVerifier(): string | null {
if (typeof window === 'undefined') {
return null;
}
const v = sessionStorage.getItem(VERIFIER_KEY);
if (v) {
sessionStorage.removeItem(VERIFIER_KEY);
}
return v;
}
+19
View File
@@ -0,0 +1,19 @@
/** RFC 7636:生成 code_verifier 与 S256 code_challenge(与 Go VerifyPKCES256 一致)。 */
function base64Url(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]!);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export async function createPKCEPair(): Promise<{ verifier: string; challenge: string }> {
const arr = new Uint8Array(32);
crypto.getRandomValues(arr);
const verifier = base64Url(arr.buffer);
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = base64Url(hash);
return { verifier, challenge };
}
+58
View File
@@ -0,0 +1,58 @@
const CH = 'smart-auth';
const LS_KEY = 'smart_logout_ping';
/** 当前标签页主动登出时通知其它标签 */
export function broadcastLogout(): void {
if (typeof window === 'undefined') {
return;
}
try {
if (typeof BroadcastChannel !== 'undefined') {
const bc = new BroadcastChannel(CH);
bc.postMessage({ type: 'logout', t: Date.now() });
bc.close();
}
} catch {
/* ignore */
}
try {
localStorage.setItem(LS_KEY, String(Date.now()));
} catch {
/* ignore */
}
}
export type LogoutListener = () => void;
/** 其它标签登出时回调(需在客户端挂载一次) */
export function subscribeRemoteLogout(onLogout: LogoutListener): () => void {
if (typeof window === 'undefined') {
return () => {};
}
let bc: BroadcastChannel | null = null;
try {
if (typeof BroadcastChannel !== 'undefined') {
bc = new BroadcastChannel(CH);
bc.onmessage = (ev: MessageEvent<{ type?: string }>) => {
if (ev.data?.type === 'logout') {
onLogout();
}
};
}
} catch {
bc = null;
}
const onStorage = (e: StorageEvent) => {
if (e.key === LS_KEY && e.newValue) {
onLogout();
}
};
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener('storage', onStorage);
bc?.close();
};
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+8064
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@radix-ui/react-accessible-icon": "^1.1.8",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-toolbar": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4",
"next": "15.5.15",
"react": "19.1.0",
"react-dom": "19.1.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.15",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.3",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+5412
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
const config = {
plugins: ['@tailwindcss/postcss'],
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+78
View File
@@ -0,0 +1,78 @@
'use client';
import { create } from 'zustand';
import { loginWithPassword, logoutRequest, type TokenPair } from '@/lib/api/auth';
import { registerTokenBridge } from '@/lib/api/client';
import { broadcastLogout } from '@/lib/sync/logout-broadcast';
const STORAGE_KEY = 'smart_auth_tokens';
function loadFromSession(): Pick<AuthState, 'accessToken' | 'refreshToken'> {
if (typeof window === 'undefined') {
return { accessToken: null, refreshToken: null };
}
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) {
return { accessToken: null, refreshToken: null };
}
const p = JSON.parse(raw) as { accessToken?: string; refreshToken?: string };
return { accessToken: p.accessToken ?? null, refreshToken: p.refreshToken ?? null };
} catch {
return { accessToken: null, refreshToken: null };
}
}
type AuthState = {
accessToken: string | null;
refreshToken: string | null;
setTokens: (access: string | null, refresh: string | null) => void;
login: (userName: string, password: string, tenantId?: string) => Promise<void>;
logout: () => Promise<void>;
};
export const useAuthStore = create<AuthState>((set) => {
const initial = loadFromSession();
const persist = (accessToken: string | null, refreshToken: string | null) => {
set({ accessToken, refreshToken });
if (typeof window !== 'undefined') {
if (accessToken || refreshToken) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ accessToken, refreshToken }));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}
};
return {
accessToken: initial.accessToken,
refreshToken: initial.refreshToken,
setTokens: (access, refresh) => {
persist(access, refresh);
},
login: async (userName, password, tenantId) => {
const pair: TokenPair = await loginWithPassword({ userName, password, tenantId });
persist(pair.accessToken, pair.refreshToken);
},
logout: async () => {
try {
await logoutRequest();
} finally {
persist(null, null);
broadcastLogout();
}
},
};
});
registerTokenBridge(
() => ({
accessToken: useAuthStore.getState().accessToken,
refreshToken: useAuthStore.getState().refreshToken,
}),
(access, refresh) => useAuthStore.getState().setTokens(access, refresh)
);
+17
View File
@@ -0,0 +1,17 @@
'use client';
import { create } from 'zustand';
type AuthUiState = {
loginModalOpen: boolean;
loginHint: string | null;
openLoginModal: (hint?: string | null) => void;
closeLoginModal: () => void;
};
export const useAuthUiStore = create<AuthUiState>((set) => ({
loginModalOpen: false,
loginHint: null,
openLoginModal: (hint) => set({ loginModalOpen: true, loginHint: hint ?? null }),
closeLoginModal: () => set({ loginModalOpen: false, loginHint: null }),
}));
+84
View File
@@ -0,0 +1,84 @@
'use client';
import { create } from 'zustand';
export type SidebarMode = 'classic' | 'icon';
const MODE_KEY = 'smart_sidebar_mode';
const RAIL_COLLAPSED_KEY = 'smart_classic_nav_rail_collapsed';
function loadRailCollapsed(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
return localStorage.getItem(RAIL_COLLAPSED_KEY) === '1';
} catch {
return false;
}
}
function persistRailCollapsed(collapsed: boolean) {
if (typeof window === 'undefined') {
return;
}
try {
localStorage.setItem(RAIL_COLLAPSED_KEY, collapsed ? '1' : '0');
} catch {
/* ignore */
}
}
function loadMode(): SidebarMode {
if (typeof window === 'undefined') {
return 'classic';
}
try {
const v = localStorage.getItem(MODE_KEY);
return v === 'icon' ? 'icon' : 'classic';
} catch {
return 'classic';
}
}
type LayoutStoreState = {
sidebarMode: SidebarMode;
/** 经典布局:窄轨仅一级 + 悬停浮层(true);false 为完整侧栏树 */
classicNavRailCollapsed: boolean;
perms: string[];
setSidebarMode: (mode: SidebarMode) => void;
setClassicNavRailCollapsed: (collapsed: boolean) => void;
toggleClassicNavRail: () => void;
setPerms: (perms: string[]) => void;
};
export const useLayoutStore = create<LayoutStoreState>((set) => ({
sidebarMode: typeof window !== 'undefined' ? loadMode() : 'classic',
classicNavRailCollapsed: typeof window !== 'undefined' ? loadRailCollapsed() : false,
perms: [],
setSidebarMode: (mode) => {
set({ sidebarMode: mode });
if (typeof window !== 'undefined') {
try {
localStorage.setItem(MODE_KEY, mode);
} catch {
/* ignore */
}
}
},
setClassicNavRailCollapsed: (collapsed) => {
persistRailCollapsed(collapsed);
set({ classicNavRailCollapsed: collapsed });
},
toggleClassicNavRail: () =>
set((s) => {
const next = !s.classicNavRailCollapsed;
persistRailCollapsed(next);
return { classicNavRailCollapsed: next };
}),
setPerms: (perms) => set({ perms }),
}));
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { create } from 'zustand';
export type AppTab = {
id: string;
title: string;
path: string;
pinned?: boolean;
};
type TabStoreState = {
tabs: AppTab[];
activeId: string;
open: (tab: Omit<AppTab, 'id'> & { id?: string }) => void;
/** 同 path 则仅激活,否则新开 */
openOrActivate: (tab: { path: string; title: string }) => void;
/** 浏览器地址变化时同步当前页签 */
syncFromPath: (path: string) => void;
/** 切换租户:保留固定「概览」并重置页签 */
resetForTenantSwitch: () => void;
close: (id: string) => string | null;
activate: (id: string) => void;
};
let seq = 0;
const overview: AppTab = {
id: 'overview',
title: '概览',
path: '/dashboard',
pinned: true,
};
function titleFromPath(path: string): string {
const parts = path.split('/').filter(Boolean);
const last = parts[parts.length - 1];
return last ? decodeURIComponent(last) : path;
}
export const useTabStore = create<TabStoreState>((set, get) => ({
tabs: [overview],
activeId: 'overview',
open: (tab) => {
const id = tab.id ?? `t-${++seq}`;
set((s) => ({
tabs: [...s.tabs, { ...tab, id }],
activeId: id,
}));
},
openOrActivate: ({ path, title }) => {
const normalized = path.startsWith('/') ? path : `/${path}`;
set((s) => {
const hit = s.tabs.find((t) => t.path === normalized);
if (hit) {
return { activeId: hit.id };
}
const id = `t-${++seq}`;
return {
tabs: [...s.tabs, { id, path: normalized, title: title || titleFromPath(normalized) }],
activeId: id,
};
});
},
syncFromPath: (path) => {
const normalized = path.startsWith('/') ? path : `/${path}`;
if (!normalized.startsWith('/dashboard')) {
return;
}
set((s) => {
const hit = s.tabs.find((t) => t.path === normalized);
if (hit) {
return { activeId: hit.id };
}
const id = `t-${++seq}`;
return {
tabs: [
...s.tabs,
{
id,
path: normalized,
title: titleFromPath(normalized),
},
],
activeId: id,
};
});
},
resetForTenantSwitch: () => {
set({
tabs: [overview],
activeId: 'overview',
});
},
close: (id) => {
const { tabs, activeId } = get();
const t = tabs.find((x) => x.id === id);
if (!t || t.pinned) {
return null;
}
const nextTabs = tabs.filter((x) => x.id !== id);
let nextActive = activeId;
if (activeId === id) {
const idx = tabs.findIndex((x) => x.id === id);
const neighbor = tabs[idx - 1] ?? tabs[idx + 1];
nextActive = neighbor?.id ?? 'overview';
}
set({ tabs: nextTabs, activeId: nextActive });
const activeTab = get().tabs.find((x) => x.id === nextActive);
return activeTab?.path ?? '/dashboard';
},
activate: (id) => set({ activeId: id }),
}));
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { create } from 'zustand';
import { registerTenantHeaderBridge } from '@/lib/api/client';
const KEY = 'smart_tenant_id';
type TenantState = {
/** 显式选择的租户;null 表示不额外传 X-Tenant-ID,由 Bearer 默认租户生效 */
tenantId: string | null;
setTenantId: (id: string | null) => void;
/** 从当前用户资料同步(登录后首次) */
hydrateFromUserTenant: (tenantId: string | undefined | null) => void;
};
function load(): string | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = sessionStorage.getItem(KEY);
if (raw === '' || raw === 'null') {
return null;
}
return raw;
} catch {
return null;
}
}
export const useTenantStore = create<TenantState>((set, get) => ({
tenantId: typeof window !== 'undefined' ? load() : null,
setTenantId: (id) => {
set({ tenantId: id });
if (typeof window !== 'undefined') {
if (id) {
sessionStorage.setItem(KEY, id);
} else {
sessionStorage.removeItem(KEY);
}
}
},
hydrateFromUserTenant: (tid) => {
if (!tid) {
return;
}
const cur = get().tenantId;
if (cur == null || cur === '') {
set({ tenantId: tid });
if (typeof window !== 'undefined') {
sessionStorage.setItem(KEY, tid);
}
}
},
}));
registerTenantHeaderBridge(() => useTenantStore.getState().tenantId);
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { create } from 'zustand';
export type ToastItem = { id: string; message: string; variant: 'error' | 'info' };
type ToastState = {
toasts: ToastItem[];
show: (message: string, variant?: ToastItem['variant']) => void;
dismiss: (id: string) => void;
};
export const useToastStore = create<ToastState>((set, get) => ({
toasts: [],
show: (message, variant = 'info') => {
const id =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: String(Date.now());
set((s) => ({ toasts: [...s.toasts, { id, message, variant }] }));
const duration = variant === 'error' ? 5200 : 3600;
if (typeof window !== 'undefined') {
window.setTimeout(() => {
get().dismiss(id);
}, duration);
}
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}));
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}