feat: 优化web
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user