前言
这一章我们来实现dashboard,因为笔者也在踩坑学习使用,所以更新会比较慢,可能也有一定的bug出现,欢迎觉我
依赖
npx shadcn-ui@latest init
初始化shadcn,一路回车就行
npx shadcn-ui@latest add
运行上面命令,就会出现让我们选择安装哪些组件,可以空格选择,这里直接把所有都选上,等待安装完成
添加表单相关依赖
pnpm i zod react-hook-form @hookform/resolvers
登录注册
新建helpers/storage.ts 用于封装localStorage操作
const createStorageInstance = (key: string) => {
const get = () => {
return localStorage.getItem(key);
};
const set = (value: string) => {
return localStorage.setItem(key, value);
};
const remove = () => {
return localStorage.removeItem(key);
};
return {
get,
set,
remove,
};
};
export const tokenStorage = createStorageInstance('token');
export const userStorage = createStorageInstance('user_info');
新建helpers/request.ts封装请求函数,主要是为了统一请求前携带Authorization
import { tokenStorage } from './storage';
const createRequestInstance = (baseURL: string, tokenKey = 'auth_token') => {
// 发送请求
const request = async (url: string, options: any = {}) => {
const headers = new Headers(options.headers || {});
const token = tokenStorage.get();
if (token) {
headers.append('Authorization', token);
}
const finalOptions = {
...options,
headers,
};
const response = await fetch(`${baseURL}${url}`, finalOptions);
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return response;
};
const get = (url: string, options: any = {}) => request(url, { ...options, method: 'GET' });
const post = (url: string, body: any, options: any = {}) =>
request(url, {
...options,
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
});
const put = (url: string, body: any, options: any = {}) =>
request(url, {
...options,
method: 'PUT',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
});
const del = (url: string, options: any = {}) => request(url, { ...options, method: 'DELETE' });
const patch = (url: string, body: any, options: any = {}) =>
request(url, {
...options,
method: 'PATCH',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
});
return {
get,
post,
put,
del,
patch,
};
};
const request = createRequestInstance('');
export default request;
toast
编辑app/layout.tsx 引入toast提示
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { Toaster } from '@/components/ui/sonner';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
{children}
<Toaster position="top-center" richColors duration={1000} />
</body>
</html>
);
}
骨架屏
新建components/SkeletonComponent.tsx
import { Skeleton } from './ui/skeleton';
export default function SkeletonComponent() {
return (
<div className="mt-10 flex flex-col items-center">
<Skeleton className="h-[125px] w-full rounded-xl" style={{ width: 'calc(100% - 30px)' }} />
<div className="my-4 w-full shrink-0 space-y-2" style={{ width: 'calc(100% - 30px)' }}>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
</div>
);
}
登录注册表单
编写登录注册表单components/AuthForm.tsx
'use client';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useSearchParams, useRouter } from 'next/navigation';
import { z } from 'zod';
import { ReloadIcon } from '@radix-ui/react-icons';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form';
import { Input } from './ui/input';
import { Button } from './ui/button';
import request from '@/helpers/request';
import { tokenStorage, userStorage } from '@/helpers/storage';
import { toast } from 'sonner';
export enum AUTH_TYPE {
LOGIN,
REGISTER,
}
export default function AuthForm(props: any) {
const [loading, setLoading] = useState(false);
const searchParams = useSearchParams();
const router = useRouter();
const isRegister = useMemo(() => props.authType === AUTH_TYPE.REGISTER, [props.authType]);
const formSchema = useMemo(
() =>
isRegister
? z.object({
email: z.string().email({
message: '请输入正确的邮箱地址',
}),
password: z.string().min(6, {
message: '密码长度至少为6位',
}),
name: z.string().min(2, {
message: '名字长度至少为2位',
}),
})
: z.object({
email: z.string().email({
message: '请输入正确的邮箱地址',
}),
password: z.string().min(6, {
message: '密码长度至少为6位',
}),
}),
[isRegister],
);
// Define your form
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
});
// Define a submit handler
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setLoading(true);
try {
const response = await request.post(
isRegister ? '/api/auth/signup' : '/api/auth/signin',
values,
);
const result = await response.json();
if (result?.code === 0) {
toast.success('登录成功');
const path = searchParams.get('redirect');
const userInfo = JSON.stringify(result.data.user);
tokenStorage.set(result.data.token);
userStorage.set(userInfo);
router.replace(path || '/dashboard');
} else {
toast.error(result?.message || '登录失败,请稍后再试');
}
} catch (err) {
toast.error('登录失败,请稍后再试');
} finally {
setLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="enter your email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{isRegister ? (
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input type="name" placeholder="enter your name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : null}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="enter your password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" /> Please wait
</>
) : (
'Submit'
)}
</Button>
</form>
</Form>
);
}
这里用到的是react-hook-form结合zod进行表单校验,以及shadcn/ui做组件,简单示例可查看:ui.shadcn.com/docs/compon…
登录注册卡片
新建components/Auth.tsx
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import AuthForm, { AUTH_TYPE } from './AuthForm';
export default function Auth() {
return (
<Tabs defaultValue="login" className="w-[400px]">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="login">登录</TabsTrigger>
<TabsTrigger value="register">注册</TabsTrigger>
</TabsList>
<TabsContent value="login">
<Card>
<CardHeader>
<CardTitle>Login</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<AuthForm authType={AUTH_TYPE.LOGIN} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="register">
<Card>
<CardHeader>
<CardTitle>Register</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<AuthForm authType={AUTH_TYPE.REGISTER} />
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
}
在app/page.tsx引入登录注册
import Auth from '@/components/Auth';
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<Auth />
</main>
);
}
dashboard页面
新建app/dashboard/layout.tsx 在这里实现读取本地token,没有的话就跳转到登录注册页面,注意这里的isMounted
'use client';
import { ReloadIcon } from '@radix-ui/react-icons';
import { useRouter, usePathname } from 'next/navigation';
import { useEffect, useState, ReactNode } from 'react';
import { tokenStorage } from '@/helpers/storage';
import SkeletonComponent from '@/components/SkeletonComponent'
export default function Layout({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
const token = tokenStorage.get();
if (!token) {
router.replace(`/?redirect=${pathname}`);
} else {
setIsMounted(true);
}
}, [router, pathname]);
if (!isMounted) {
return <SkeletonComponent />
}
return (
<div>
header
{children}
</div>
);
}
在app/dashboard/page.tsx编写一个测试页面
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
</div>
);
}
效果如下:
dashboard页面公共区域
7.25更新
今天来实现dashboard的框架PC大屏效果如下:
小屏效果:
User组件
首先我们来实现右上角的User组件,这里随便找一张头像图片放在public下面即可,新建app/dashboard/User.tsx
'use client';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuItem,
} from '@/components/ui/dropdown-menu';
import { userStorage } from '@/helpers/storage';
import Image from 'next/image';
import Link from 'next/link';
export default function User() {
const userInfo = JSON.parse(userStorage.get() || '{}');
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="overflow-hidden rounded-full">
<Image
src={'/placeholder-user.jpg'}
width={36}
height={36}
alt="Avatar"
className="overflow-hidden rounded-full"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{userInfo?.email}</DropdownMenuLabel>
<DropdownMenuLabel>{userInfo?.name}</DropdownMenuLabel>
<DropdownMenuSeparator />
{userInfo?.email ? (
<DropdownMenuItem>
<Link href={'/'}>Sign Out</Link>
</DropdownMenuItem>
) : (
<DropdownMenuItem>
<Link href={'/'}>Sign In</Link>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
这里用到前面我们登录存储的用户信息,email和name,然后是很简单的展开组件
Providers
这个组件主要是为了包裹app/dashboard/*下面的所有路由给他们提供统一的注入,新建app/dashboard/Providers.tsx,提供了Tooltip的注入
'use client';
import { TooltipProvider } from '@/components/ui/tooltip';
import React from 'react';
export default function Providers({ children }: { children: React.ReactNode }) {
return <TooltipProvider>{children}</TooltipProvider>;
}
NavItem
然后是NavItem用于展示sidebar的菜单条目
新建app/dashboard/NavItem.tsx
'use client';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import clsx from 'clsx';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export default function NavItem({
href,
label,
children,
}: {
href: string;
label: string;
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={href}
className={clsx(
'flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground md:h-8 md:w-8',
{
'bg-accent text-black': pathname === href,
},
)}
>
{children}
<span className="sr-only">{label}</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
);
}
主要是为了显示按钮点击跳转
dashboard layout
最后就是本次dashboard路由的公共部分了,修改app/dashboard/layout.tsx
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { useEffect, useState, ReactNode } from 'react';
import { tokenStorage } from '@/helpers/storage';
import SkeletonComponent from '@/components/SkeletonComponent';
import User from './User';
import Providers from './Providers';
import Link from 'next/link';
import NavItem from './NavItem';
import {
Home,
Settings,
User as UserIcon,
MonitorCog,
Boxes,
ShieldAlert,
Contact,
PanelLeft,
Package2,
Smile,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Sheet, SheetTrigger, SheetContent } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
export default function Layout({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
const token = tokenStorage.get();
if (!token) {
router.replace(`/?redirect=${pathname}`);
} else {
setIsMounted(true);
}
}, [router, pathname]);
if (!isMounted) {
return <SkeletonComponent />;
}
return (
<Providers>
<main className="flex min-h-screen w-full flex-col bg-muted/40">
<DesktopNav />
<div className="flex flex-col sm:gap-4 sm:py-4 sm:pl-14">
<header className="sticky top-0 z-30 flex h-14 items-center justify-between gap-4 border-b bg-background px-4 sm:static sm:h-auto sm:border-0 sm:bg-transparent sm:px-6">
<MobileNav />
<DashboardBreadcrumb pathName={pathname} />
<User />
</header>
<main className="grid flex-1 items-start gap-2 bg-muted/40 p-4 sm:px-6 sm:py-0 md:gap-4">
{children}
</main>
</div>
</main>
</Providers>
);
}
function DesktopNav() {
return (
<aside className="fixed inset-y-0 left-0 z-10 hidden w-14 flex-col border-r bg-background sm:flex">
<nav className="flex flex-col items-center gap-4 px-2 sm:py-5">
<Link
href="/dashboard"
className="group flex h-9 w-9 shrink-0 items-center justify-center gap-2 rounded-full bg-primary text-lg font-semibold text-primary-foreground md:h-8 md:w-8 md:text-base"
>
<Smile className="h-5 w-5 transition-all group-hover:scale-110" />
<span className="sr-only">11</span>
</Link>
<NavItem href="/dashboard" label="Dashboard">
<Home className="h-5 w-5" />
</NavItem>
<NavItem href="/dashboard/user" label="User">
<UserIcon className="h-5 w-5" />
</NavItem>
<NavItem href="/dashboard/system" label="System">
<MonitorCog className="h-5 w-5" />
</NavItem>
<NavItem href="/dashboard/source" label="Source">
<Boxes className="h-5 w-5" />
</NavItem>
<NavItem href="/dashboard/privilege" label="Privilege">
<ShieldAlert className="h-5 w-5" />
</NavItem>
<NavItem href="/dashboard/role" label="Role">
<Contact className="h-5 w-5" />
</NavItem>
</nav>
<nav className="mt-auto flex flex-col items-center gap-4 px-2 sm:py-5">
<Tooltip>
<TooltipTrigger asChild>
<Link
href="#"
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground md:h-8 md:w-8"
>
<Settings className="h-5 w-5" />
<span className="sr-only">Settings</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">Settings</TooltipContent>
</Tooltip>
</nav>
</aside>
);
}
function DashboardBreadcrumb({ pathName }: { pathName: string }) {
const parts = pathName?.split('/')?.filter(Boolean);
return (
<Breadcrumb className="hidden md:flex">
<BreadcrumbList>
{parts?.map((item: any, index: number) => (
<>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={'/' + parts.slice(0, index + 1)?.join('/')}>{item}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{index < parts.length - 1 && <BreadcrumbSeparator />}
</>
))}
</BreadcrumbList>
</Breadcrumb>
);
}
function MobileNav() {
return (
<Sheet>
<SheetTrigger asChild>
<Button size="icon" variant="outline" className="sm:hidden">
<PanelLeft className="h-5 w-5" />
<span className="sr-only">Toggle Menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="sm:max-w-xs">
<nav className="grid gap-6 text-lg font-medium">
<Link
href="/dashboard"
className="group flex h-10 w-10 shrink-0 items-center justify-center gap-2 rounded-full bg-primary text-lg font-semibold text-primary-foreground md:text-base"
>
<Package2 className="h-5 w-5 transition-all group-hover:scale-110" />
<span className="sr-only">low-code</span>
</Link>
<Link
href="/dashboard"
className="flex items-center gap-4 px-2.5 text-muted-foreground hover:text-foreground"
>
<Home className="h-5 w-5" />
Home
</Link>
<Link
href="/dashboard/user"
className="flex items-center gap-4 px-2.5 text-muted-foreground hover:text-foreground"
>
<UserIcon className="h-5 w-5" />
User
</Link>
<Link
href="/dashboard/system"
className="flex items-center gap-4 px-2.5 text-muted-foreground hover:text-foreground"
>
<MonitorCog className="h-5 w-5" />
System
</Link>
<Link
href="/dashboard/source"
className="flex items-center gap-4 px-2.5 text-muted-foreground hover:text-foreground"
>
<Boxes className="h-5 w-5" />
Source
</Link>
<Link
href="/dashboard/privilege"
className="flex items-center gap-4 px-2.5 text-muted-foreground hover:text-foreground"
>
<ShieldAlert className="h-5 w-5" />
Privilege
</Link>
<Link
href="/dashboard/role"
className="flex items-center gap-4 px-2.5 text-muted-foreground hover:text-foreground"
>
<Contact className="h-5 w-5" />
Role
</Link>
</nav>
</SheetContent>
</Sheet>
);
}
这里我们实现了PC端和小屏端的sidebar,以及beadcrumb面包屑,由于layout的部分可以下面所有的路由共享,所以我们用来做了header,sidebar
注意:我们还需要在app/dashboard/*下面新增几个页面,才能正常路由跳转,否则就是404了,这里就不一一粘贴代码了
目前效果如下:
未完待续,后续开始更新系统管理,资源管理等