JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第67章—实战篇-t3-app实战-身份认证与主题切换

首次发表于 2024-07-29, 更新于 2024-07-29

前言

我们使用 Typescript + Tailwind + Prisma + MySQL + Zod + Shadcn UI + React Hook Form + Clerk + Server Actions 开发一个清单项目。

项目效果如下:

t32.gif

1. 项目初始化

运行:

npm create t3-app@latest
  1. Will you be using TypeScript or JavaScript?
    1. TypeScript
  2. Will you be using Tailwind CSS for styling?
    1. Yes
  3. Would you like to use tRPC?
    1. No
  4. What authentication provider would you like to use?
    1. None(因为接入 Clerk)
  5. What database ORM would you like to use?
    1. Prisma
  6. Would you like to use Next.js App Router?
    1. Yes
  7. What database provider would you like to use?
    1. MySQL(选择喜欢的数据库即可,使用 Prisma)
  8. Should we initialize a Git repository and stage the changes?
    1. Yes
  9. Should we run 'npm install' for you?
    1. Yes
  10. What import alias would you like to use?
  11. @(按照个人习惯设置即可)

本地开启 MySQL 数据库,修改 .env中的数据库地址:

# 修改用户名、密码,用于连接本地数据库
# next-t3-todo 表示数据库名,数据库不需要先行创建
DATABASE_URL="mysql://username:password@localhost:3306/next-t3-todo"

运行:

# 进入项目目录
cd next-t3-todo
# 相当于 prisma db push
npm run db:push

效果如下:

image.png

此时会创建数据库,并将数据库和 Prisma schema 同步。

运行:

# 开发模式
npm run dev
# 提交代码
git commit -m "initial commit"

我们的项目就正式开始了。

2. 功能:身份认证

我们先做身份认证,毕竟身份认证是基础,且创建清单的时候需要用户信息。

为了快速实现,最便捷的方式是接入 Clerk。我们将接入 Clerk 并实现界面的汉化。

2.1. 接入 Clerk

安装依赖项:

# 安装依赖项,其中 lodash.merge 用于界面汉化
npm i --save @clerk/localizations @clerk/nextjs lodash.merge
# 安装开发依赖项
npm i --save-dev @types/lodash.merge

Clerk 创建一个应用,创建后查看密钥信息。

项目根目录新建 .env.local文件,代码如下:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CLERK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

新建 src/middleware.ts文件,代码如下:

import { clerkMiddleware } from "@clerk/nextjs/server";

export default clerkMiddleware();

export const config = {
  matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};

注意: middleware.ts 并不一定放在项目根目录,放在与 app 同级目录位置

修改 src/app/layout.tsx,代码如下:

import "@/styles/globals.css";

import { type Metadata } from "next";
import {
  ClerkProvider,
  SignInButton,
  SignedIn,
  SignedOut,
  UserButton,
} from "@clerk/nextjs";

export const metadata: Metadata = {
  title: "Create T3 App",
  description: "Generated by create-t3-app",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>
          <header>
            <SignedOut>
              <SignInButton />
            </SignedOut>
            <SignedIn>
              <UserButton />
            </SignedIn>
          </header>
          <main>{children}</main>
        </body>
      </html>
    </ClerkProvider>
  );
}

重新运行 npm run dev,此时效果如下:

image.png

2.2. 自定义登录和注册地址

当我们点击 Sign in 的时候,跳转的其实是 Clerk 的地址,如果要修改为我们自己的路由地址该怎么做呢?

新建 src/app/(auth)/sign-in/[[...sign-in]]/page.tsx,代码为:

import { SignIn } from "@clerk/nextjs";

export default function Page() {
  return <SignIn />;
}

新建 src/app/(auth)/sign-up/[[...sign-up]]/page.tsx,代码为:

import { SignUp } from "@clerk/nextjs";

export default function Page() {
  return <SignUp />;
}

修改 .env.local文件,添加如下代码:

NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

重新运行 npm run dev,浏览器效果如下:

t33.gif

效果描述:当点击登录的时候,跳转的是 http://localhost/sign-in

2.3. 界面汉化

修改 src/app/layout.tsx,完整代码如下:

import "@/styles/globals.css";

import { type Metadata } from "next";
import {
  ClerkProvider,
  SignInButton,
  SignedIn,
  SignedOut,
  UserButton,
} from "@clerk/nextjs";
// Step1: 引入汉化文件
import { zhCN } from "@clerk/localizations";

export const metadata: Metadata = {
  title: "Create T3 App",
  description: "Generated by create-t3-app",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  // Step2: 设置 ClerkProvider 的 localization
  return (
    <ClerkProvider localization={zhCN}>
      {/** Step3: 别忘了 html lang */}
      <html lang="zh-CN">
        <body>
          <header>
            <SignedOut>
              {/** Step4: 如果按钮文案要改为中文 */}
              <SignInButton>登录</SignInButton>
            </SignedOut>
            <SignedIn>
              <UserButton />
            </SignedIn>
          </header>
          <main>{children}</main>
        </body>
      </html>
    </ClerkProvider>
  );
}

此时界面基本完成汉化,浏览器效果如下:

t34.gif

但查看用户界面,你就会发现,这个汉化并不全面!就比如删除按钮这里的文案依然是英文……

新建 src/locales/zh.json,代码如下:

{
  "userProfile": {
    "start": {
      "dangerSection": {
        "deleteAccountButton": "删除账户",
        "title": "账户终止"
      }
    }
  }
}

修改 src/app/layout.tsx,代码如下:

import "@/styles/globals.css";

import { type Metadata } from "next";
import {
  ClerkProvider,
  SignInButton,
  SignedIn,
  SignedOut,
  UserButton,
} from "@clerk/nextjs";
import { zhCN } from "@clerk/localizations";
// Step1: 引入翻译文件
import zhCNlocales from "@/locales/zh.json";
import merge from "lodash.merge";

export const metadata: Metadata = {
  title: "Create T3 App",
  description: "Generated by create-t3-app",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  // Step2: 合并翻译文件
  const localization = merge(zhCN, zhCNlocales);
  // Step3: 设置 ClerkProvider 的 localization
  return (
    <ClerkProvider localization={localization}>
      <html lang="zh-CN">
        <body>
          <header>
            <SignedOut>
              <SignInButton>登录</SignInButton>
            </SignedOut>
            <SignedIn>
              <UserButton />
            </SignedIn>
          </header>
          <main>{children}</main>
        </body>
      </html>
    </ClerkProvider>
  );
}

此时删除账户界面就改为了中文:

image.png

那么问题来了:如果还有其他需要汉化的位置,怎么知道具体的字段位置呢?就比如为什么“删除账户”,它对应的字段位置是 userProfile.start.dangerSection.deleteAccountButton 呢?

其实这是个体力活:打开原本的英文,搜索对应文案,就可以找到具体的字段位置了。

注意:目前中文翻译并不全面,这是一个给 Clerk 提 PR 的好机会!

2.4. 设置路由保护

毕竟我们开发的是一个清单项目,当用户打开首页的时候,应该是登录后才能查看到自己创建的清单。所以我们实现一个路由保护,当用户访问 /的时候,会跳转到 /sign-in,引导用户登录。

修改 src/middleware.ts,完整代码如下:

import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]);

export default clerkMiddleware((auth, request) => {
  if (!isPublicRoute(request)) {
    auth().protect();
  }
});

export const config = {
  matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};

在这段代码中,我们设置了 /sign-in/sign-up 为公共路由,访问其他路由,都会触发路由保护,跳转到登录页面。

注意:这里中间件的 matcher 逻辑是,除了内部路由 _next 和静态文件之外,其他都会受到保护

修改 src/app/layout.tsx,代码如下:

import "@/styles/globals.css";

import { type Metadata } from "next";
import {
  ClerkProvider,
  SignInButton,
  SignedIn,
  SignedOut,
  UserButton,
} from "@clerk/nextjs";
import { zhCN } from "@clerk/localizations";
import zhCNlocales from "@/locales/zh.json";
import merge from "lodash.merge";

export const metadata: Metadata = {
  title: "Create T3 App",
  description: "Generated by create-t3-app",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  const localization = merge(zhCN, zhCNlocales);
  return (
    <ClerkProvider localization={localization}>
      <html lang="zh-CN">
        <body>
          {/* 注释或删除下面这段代码,在使用路由保护的时候会导致错误 */}
          {/* <header>
            <SignedOut>
              <SignInButton>登录</SignInButton>
            </SignedOut>
            <SignedIn>
              <UserButton />
            </SignedIn>
          </header> */}
          <main>{children}</main>
        </body>
      </html>
    </ClerkProvider>
  );
}

这是因为使用 <SignedIn><SignedOut> 组件会导致报错:

image.png

现在我们就完成了 Clerk 的基础设置。浏览器效果如下:

t35.gif

效果描述:访问首页,会跳转到登录页面,登录完成后,跳转会首页。

3. 功能:支持深色模式

接下来我们支持深色模式,我们借助 Shadcn UI 实现。

3.1. 接入 Shadcn UI

初始化 Shadcn UI:

npx shadcn-ui@latest init

命令行效果如下:

image.png

添加组件:

npx shadcn-ui@latest add

因为用到的组件很多,干脆全装了。敲击键盘的a,作用是全选组件(再敲击一次就是取消全选),然后进行安装。

3.2. 实现主题切换器

修改 src/app/page.tsx,代码如下:

export default function HomePage() {
  return (
    <main className="flex w-full flex-col items-center">
      <div>Hello World!</div>
    </main>
  );
}

修改 src/app/layout.tsx,完整代码如下:

import "@/styles/globals.css";

import { type Metadata } from "next";
import { ClerkProvider } from "@clerk/nextjs";
import { zhCN } from "@clerk/localizations";
import zhCNlocales from "@/locales/zh.json";
import merge from "lodash.merge";
// Step1: 添加组件
import ThemeProvider from "@/components/ThemeProvider";
import Header from "@/components/Header";

export const metadata: Metadata = {
  title: "Create T3 App",
  description: "Generated by create-t3-app",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  const localization = merge(zhCN, zhCNlocales);
  return (
    <ClerkProvider localization={localization}>
      {/* Step2: 设置 suppressHydrationWarning */}
      <html lang="zh-CN" suppressHydrationWarning>
        <body>
          {/* Step3: 设置 ThemeProvider */}
          <ThemeProvider
            attribute="class"
            defaultTheme="system"
            enableSystem
            disableTransitionOnChange
          >
            <Header />
            <div className="flex w-full flex-col items-center">{children}</div>
          </ThemeProvider>
        </body>
      </html>
    </ClerkProvider>
  );
}

新建 src/components/ThemeProvider.tsx,代码如下:

"use client";

import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";

export default function ThemeProvider({
  children,
  ...props
}: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

新建 src/components/Header.tsx,代码如下:

import { UserButton } from "@clerk/nextjs";
import ThemeToggle from "./ThemeToggle";

export default function Header() {
  return (
    <nav className="flex h-[60px] w-full items-center justify-between p-4">
      <h1>嗒嗒清单</h1>
      <div className="flex items-center gap-2">
        <UserButton />
        <ThemeToggle />
      </div>
    </nav>
  );
}

新建 src/components/ThemeToggle.tsx,代码如下:

"use client";

import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export default function ModeToggle() {
  const { setTheme } = useTheme();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

此时浏览器效果如下:

t3-6.gif

注:更复杂的效果,比如修改主题色、增加主题请查看《实战篇 | Shadcn UI 与组件库》

4. 功能:欢迎信息

修改 src/app/page.tsx,完整代码如下:

import { Suspense } from "react";
import {
  Card,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { currentUser } from "@clerk/nextjs/server";
import CreateListModal from "@/components/createListModal";

async function Welcome() {
  const user = await currentUser();

  if (!user) return null;

  return (
    <Card className="w-full sm:col-span-2" x-chunk="dashboard-05-chunk-0">
      <CardHeader className="pb-3">
        <CardTitle className="text-lg">
          欢迎 {user.firstName} {user.lastName}!
        </CardTitle>
        <CardDescription className="max-w-lg text-balance leading-relaxed">
          道虽迩,不行不至;事虽小,不为不成
        </CardDescription>
      </CardHeader>
      <CardFooter>
        <CreateListModal />
      </CardFooter>
    </Card>
  );
}

function WelcomeFallback() {
  return <Skeleton className="h-[180px] w-full" />;
}

export default function HomePage() {
  return (
    <main className="flex w-full flex-col items-center px-4">
      <Suspense fallback={<WelcomeFallback />}>
        <Welcome />
      </Suspense>
    </main>
  );
}

在这段代码中,我们创建了一个 <Welcome>组件,当涉及到服务端请求时,应该尽可能将请求放到 <Suspense> 中,这样就不会阻塞页面的请求和渲染。

新建 src/components/CreateListModal.tsx,代码如下:

"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet";

export default function Sidebar() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button>添加清单</Button>
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>添加清单</SheetTitle>
          <SheetDescription>
            清单是任务的集合,比如“工作”、“生活”、“副业”
          </SheetDescription>
        </SheetHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="name" className="text-right">
              清单名称:
            </Label>
            <Input
              id="name"
              value="工作"
              onChange={() => {
                console.log(1);
              }}
              className="col-span-3"
              />
          </div>
        </div>
        <SheetFooter>
          <SheetClose asChild>
            <Button type="submit">创建</Button>
          </SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  );
}

此时浏览器效果如下:

t3-7.gif

下一篇

  1. 功能实现:t3-app 身份认证和深色模式
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/next-t3-todo
  3. 下载代码:git clone -b next-t3-todo git@github.com:mqyqingfeng/next-app-demo.git

目前我们已经用 Clerk 实现了身份认证,使用 Shadcn UI + next-themes 实现了深色模式切换。当点击“添加清单”按钮的时候,右侧会弹出创建清单的表单,现在我们只是简单模拟了下大致效果。下一篇我们会用 Shadcn UI + React Hook Form + Zod + Server Actions 实现清单的创建和查询功能。

© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru