feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
@@ -0,0 +1,22 @@
'use client';
import { IamSectionCard } from '@/components/iam/IamSectionCard';
import { MenuTreeView } from '@/components/iam/MenuTreeView';
import { useApi } from '@/lib/hooks/use-api';
import { iamMenu } from '@/lib/api/iam';
import type { MenuNode } from '@/lib/api/types/menu';
export default function IamResourcePage() {
const { data, loading, error } = useApi<MenuNode[]>(() => iamMenu.tree());
return (
<IamSectionCard
title="资源(菜单)"
description="全局菜单树,对接 GET /api/v1/iam/menu/tree;与侧栏 nav 数据源一致(nav 会按角色过滤)。"
>
{loading ? <p className="text-sm text-neutral-500"></p> : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{!loading && !error && data ? <MenuTreeView tree={data} /> : null}
</IamSectionCard>
);
}
@@ -0,0 +1,46 @@
'use client';
import { IamSectionCard } from '@/components/iam/IamSectionCard';
import { useApi } from '@/lib/hooks/use-api';
import { iamTenant } from '@/lib/api/iam';
import type { IamTenant } from '@/lib/api/types/tenant';
export default function IamTenantPage() {
const { data, loading, error } = useApi(() => iamTenant.list({ page: '1', page_size: '100' }));
const rows = data?.items ?? [];
return (
<IamSectionCard
title="租户管理"
description="对接 GET /api/v1/iam/tenant/list,后续可接新增/编辑/删除。"
>
{loading ? <p className="text-sm text-neutral-500"></p> : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{!loading && !error ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[480px] border-collapse text-sm">
<thead>
<tr className="border-b border-neutral-200 text-left text-neutral-600">
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2">ID</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-neutral-100">
<td className="py-2 pr-2">{r.tenant_name ?? '—'}</td>
<td className="py-2 pr-2 font-mono text-xs">{r.tenant_code ?? '—'}</td>
<td className="py-2 pr-2">{r.status ?? '—'}</td>
<td className="py-2 font-mono text-xs text-neutral-500">{r.id}</td>
</tr>
))}
</tbody>
</table>
{!rows.length ? <p className="mt-2 text-neutral-500"></p> : null}
</div>
) : null}
</IamSectionCard>
);
}
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { DeptTreeView } from '@/components/iam/DeptTreeView';
import { IamSectionCard } from '@/components/iam/IamSectionCard';
import { useApi } from '@/lib/hooks/use-api';
import { iamDept } from '@/lib/api/iam';
import type { DeptNode } from '@/lib/api/types/dept';
export default function IamDeptPage() {
const { data, loading, error } = useApi<DeptNode[]>(() => iamDept.tree());
return (
<IamSectionCard title="部门管理" description="对接 GET /api/v1/iam/dept/tree。">
{loading ? <p className="text-sm text-neutral-500"></p> : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{!loading && !error && data ? <DeptTreeView tree={data} /> : null}
</IamSectionCard>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { IamSectionCard } from '@/components/iam/IamSectionCard';
import { useApi } from '@/lib/hooks/use-api';
import { iamRole } from '@/lib/api/iam';
import type { IamRole } from '@/lib/api/types/role';
export default function IamRolePage() {
const { data, loading, error } = useApi(() => iamRole.list({ page: '1', page_size: '50' }));
const rows = data?.items ?? [];
return (
<IamSectionCard title="角色管理" description="对接 GET /api/v1/iam/role/list。">
{loading ? <p className="text-sm text-neutral-500"></p> : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{!loading && !error ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[480px] border-collapse text-sm">
<thead>
<tr className="border-b border-neutral-200 text-left text-neutral-600">
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2">ID</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-neutral-100">
<td className="py-2 pr-2">{r.role_name ?? '—'}</td>
<td className="py-2 pr-2 font-mono text-xs">{r.role_code ?? '—'}</td>
<td className="py-2 pr-2">{r.data_scope ?? '—'}</td>
<td className="py-2 font-mono text-xs text-neutral-500">{r.id}</td>
</tr>
))}
</tbody>
</table>
{!rows.length ? <p className="mt-2 text-neutral-500"></p> : null}
</div>
) : null}
</IamSectionCard>
);
}
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { IamSectionCard } from '@/components/iam/IamSectionCard';
import { useApi } from '@/lib/hooks/use-api';
import { iamUser } from '@/lib/api/iam';
import type { IamUser } from '@/lib/api/types/user';
export default function IamUserPage() {
const { data, loading, error } = useApi(() => iamUser.list({ page: '1', page_size: '20' }));
const rows = data?.items ?? [];
return (
<IamSectionCard title="用户管理" description="对接 GET /api/v1/iam/user/list。">
{loading ? <p className="text-sm text-neutral-500"></p> : null}
{error ? <p className="text-sm text-red-600">{error}</p> : null}
{!loading && !error ? (
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] border-collapse text-sm">
<thead>
<tr className="border-b border-neutral-200 text-left text-neutral-600">
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2 pr-2"></th>
<th className="py-2">ID</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-neutral-100">
<td className="py-2 pr-2">{r.user_name ?? '—'}</td>
<td className="py-2 pr-2">{r.real_name ?? '—'}</td>
<td className="py-2 pr-2">{r.status ?? '—'}</td>
<td className="py-2 font-mono text-xs text-neutral-500">{r.id}</td>
</tr>
))}
</tbody>
</table>
{!rows.length ? <p className="mt-2 text-neutral-500"></p> : null}
</div>
) : null}
</IamSectionCard>
);
}
@@ -0,0 +1,8 @@
export default function AccountPage() {
return (
<div className="w-full rounded-lg bg-white p-4 text-neutral-700 shadow-sm">
<h1 className="text-lg font-medium"></h1>
<p className="mt-2 text-sm text-neutral-500"> IAM</p>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function DashboardPage() {
return (
<div className="w-full rounded-lg bg-white p-4 text-neutral-700 shadow-sm">
<h1 className="text-lg font-medium"></h1>
<p className="mt-2 text-sm text-neutral-500"> Tabs + </p>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
/**
* 单一布局包裹 /dashboard 与 /user 等路由,避免 Tab 在二者间切换时卸载布局、
* 导致 AppChrome 重挂载并重复请求侧栏菜单。
*/
export default function MainLayout({ children }: { children: React.ReactNode }) {
return <AuthenticatedLayout>{children}</AuthenticatedLayout>;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+55
View File
@@ -0,0 +1,55 @@
@import 'tailwindcss';
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
/* 收起态侧栏:一级悬停展开的二级浮层入场(略慢、缓出更柔和) */
@keyframes cascade-flyout-in {
from {
opacity: 0;
transform: translateX(-6px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.cascade-flyout-panel {
animation: cascade-flyout-in 0.4s cubic-bezier(0.33, 1, 0.68, 1) both;
}
@keyframes cascade-flyout-sub-in {
from {
opacity: 0;
transform: translateX(-4px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.cascade-flyout-sub {
animation: cascade-flyout-sub-in 0.32s cubic-bezier(0.33, 1, 0.68, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.cascade-flyout-panel,
.cascade-flyout-sub {
animation: none;
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { AppProviders } from '@/components/providers/AppProviders';
import './globals.css';
const geistSans = Geist({
variable: '--font-geist-sans',
subsets: ['latin'],
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
});
export const metadata: Metadata = {
title: 'Smart Go',
description: 'Smart Go 管理平台',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<AppProviders>{children}</AppProviders>
</body>
</html>
);
}
+128
View File
@@ -0,0 +1,128 @@
'use client';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
import { safeReturnPath } from '@/lib/navigation/safe-return';
import { useAuthStore } from '@/stores/auth-store';
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const accessToken = useAuthStore((s) => s.accessToken);
const login = useAuthStore((s) => s.login);
const [mounted, setMounted] = useState(false);
const [user, setUser] = useState('');
const [pass, setPass] = useState('');
const [tenant, setTenant] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted || !accessToken) {
return;
}
const next = safeReturnPath(searchParams.get('from'), '/dashboard');
router.replace(next);
}, [mounted, accessToken, router, searchParams]);
if (!mounted) {
return (
<main className="flex min-h-screen items-center justify-center text-sm text-neutral-500">
</main>
);
}
if (accessToken) {
return (
<main className="flex min-h-screen items-center justify-center text-sm text-neutral-500">
</main>
);
}
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setErr(null);
setLoading(true);
try {
await login(user, pass, tenant || undefined);
const next = safeReturnPath(searchParams.get('from'), '/dashboard');
router.replace(next);
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-6">
<form
onSubmit={onSubmit}
className="w-full max-w-sm rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
>
<h1 className="text-lg font-medium"></h1>
<label className="mt-4 block text-sm">
<span className="text-neutral-600"></span>
<input
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
value={user}
onChange={(e) => setUser(e.target.value)}
autoComplete="username"
required
/>
</label>
<label className="mt-3 block text-sm">
<span className="text-neutral-600"></span>
<input
type="password"
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
value={pass}
onChange={(e) => setPass(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<label className="mt-3 block text-sm">
<span className="text-neutral-600"> ID</span>
<input
className="mt-1 w-full rounded border border-neutral-300 px-2 py-1"
value={tenant}
onChange={(e) => setTenant(e.target.value)}
/>
</label>
{err ? <p className="mt-3 text-sm text-red-600">{err}</p> : null}
<button
type="submit"
disabled={loading}
className="mt-4 w-full rounded bg-neutral-900 py-2 text-white disabled:opacity-50"
>
{loading ? '提交中…' : '登录'}
</button>
</form>
<Link href="/" className="text-sm text-blue-600">
</Link>
</main>
);
}
export default function LoginPage() {
return (
<Suspense
fallback={
<main className="flex min-h-screen items-center justify-center text-sm text-neutral-500">
</main>
}
>
<LoginForm />
</Suspense>
);
}
+67
View File
@@ -0,0 +1,67 @@
'use client';
import { exchangeCodeForTokens } from '@/lib/api/auth';
import { getOAuthClientId, getOAuthRedirectUri } from '@/lib/env';
import { takeStoredPkceVerifier } from '@/lib/oauth/browser';
import { useAuthStore } from '@/stores/auth-store';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react';
function CallbackInner() {
const sp = useSearchParams();
const router = useRouter();
const [msg, setMsg] = useState<string>('处理中…');
useEffect(() => {
const code = sp.get('code');
const err = sp.get('error');
if (err) {
setMsg(sp.get('error_description') || err);
return;
}
if (!code) {
setMsg('缺少授权码');
return;
}
const verifier = takeStoredPkceVerifier();
if (!verifier) {
setMsg('缺少 PKCE verifier,请从授权入口重新登录');
return;
}
(async () => {
try {
const pair = await exchangeCodeForTokens({
code,
codeVerifier: verifier,
clientId: getOAuthClientId(),
redirectUri: getOAuthRedirectUri(),
});
useAuthStore.getState().setTokens(pair.accessToken, pair.refreshToken);
setMsg('登录成功,正在跳转…');
router.replace('/dashboard');
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e));
}
})();
}, [sp, router]);
return (
<main className="flex min-h-screen items-center justify-center p-6">
<p className="text-neutral-600">{msg}</p>
</main>
);
}
export default function OAuthCallbackPage() {
return (
<Suspense
fallback={
<main className="flex min-h-screen items-center justify-center">
<p></p>
</main>
}
>
<CallbackInner />
</Suspense>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Link from 'next/link';
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-8">
<h1 className="text-2xl font-semibold text-neutral-800">Smart Go · Web</h1>
<p className="max-w-md text-center text-neutral-600">
Go `NEXT_PUBLIC_API_ORIGIN` + PKCE token OAuth
</p>
<div className="flex flex-wrap justify-center gap-3">
<Link href="/login" className="rounded-lg bg-neutral-900 px-5 py-2 text-white">
</Link>
<Link
href="/dashboard"
className="rounded-lg border border-neutral-300 px-5 py-2 text-neutral-800"
>
</Link>
</div>
</main>
);
}