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