「4」基于Next.js的低代码平台:用户服务开发(下)

518 阅读4分钟

前言

这一章我们来实现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>
  );
}

效果如下:

Area.gif

dashboard页面公共区域

7.25更新

今天来实现dashboard的框架PC大屏效果如下:

image.png

小屏效果:

image.png

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>
  );
}

这里用到前面我们登录存储的用户信息,emailname,然后是很简单的展开组件

image.png

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的部分可以下面所有的路由共享,所以我们用来做了headersidebar

注意:我们还需要在app/dashboard/*下面新增几个页面,才能正常路由跳转,否则就是404了,这里就不一一粘贴代码了

image.png

目前效果如下:

Area.gif

未完待续,后续开始更新系统管理,资源管理等