Featured image of post 使用Next.js构建多租户SaaS应用的前端集成指南

使用Next.js构建多租户SaaS应用的前端集成指南

本文详细介绍了如何使用Next.js构建多租户教育科技SaaS应用的前端集成,包括权限控制实现、状态管理、组件开发和路由配置等技术内容。

前端集成:在Next.js中实现授权

在本系列文章的第一部分,我们使用Appwrite实现了后端,安装了一些依赖项,并设置了Permit来处理授权和基于角色的访问控制。

现在让我们看看如何将前端与后端集成,构建一个功能完整的教育科技SaaS应用。

前端集成:在Next.js中实现授权

既然您已经使用Permit设置了后端授权,现在将其集成到Next.js前端中。前端应该:

  • 从后端获取用户权限,控制用户可以查看和执行的操作
  • 确保API请求遵循基于角色的访问控制(RBAC)
  • 对未经授权的用户隐藏UI元素(例如,防止学生看到"创建作业")

1. 设置带授权的API调用

由于只有后端强制执行权限,您的前端从不直接决定访问权限,而是:

  • 向后端发送请求
  • 等待后端的授权响应
  • 相应地显示数据或UI元素

要开始,您需要在计算机上安装Node.js。

然后按照以下步骤操作:

1
2
npx create-next-app@latest frontend
cd frontend

2. 初始化shadcn

创建Next.js项目后,您会注意到Tailwind CSS v4已经默认安装,这意味着您不需要做其他任何事情。因为我们正在使用组件库,我们将安装Shadcn UI。

为此,我们需要运行init命令在文件夹根目录创建components.json文件:

1
npx shadcn@latest init

初始化后,您可以开始向项目添加组件:

1
npx shadcn@latest add button card dialog input label table select tabs

如果询问是否因为Next.js 15版本与shadcn的兼容性而使用force,请按Enter继续。

3. 安装所需包

安装以下包:

1
2
npm i lucide-react zustand
npm i --save-dev axios

现在我们已经安装了构建应用程序所需的所有内容,可以开始创建其他组件和路由。

为了保持整个应用程序的UI一致性,将此代码粘贴到您的global.css文件中(粘贴到tailwindcss导入下方):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@layer base {
  :root {
    --background: 75 29% 95%;           /* 浅米色背景 */
    --foreground: 0 0% 9%;              /* 深灰/黑色文本 */

    --card: 0 0% 100%;                  /* 纯白色(卡片背景) */
    --card-foreground: 0 0% 9%;         /* 深灰/黑色文本 */

    --popover: 0 0% 99%;                /* 轻微灰白色(弹出框) */
    --popover-foreground: 0 0% 9%;      /* 深灰/黑色文本 */

    --primary: 0 0% 0%;                 /* 黑色(主要标题) */
    --primary-foreground: 60 100% 100%; /* 主要元素上的白色文本 */

    --secondary: 75 31% 95%;            /* 浅灰色(用于部分区域) */
    --secondary-foreground: 0 0% 9%;    /* 深灰/黑色文本 */

    --muted: 69 30% 95%;                /* 灰色(静音文本) */
    --muted-foreground: 0 0% 45%;       /* 中灰色(边框/线条) */

    --accent: 252 29% 97%;              /* 略带蓝色的灰色(UI元素) */
    --accent-foreground: 0 0% 9%;       /* 深灰/黑色文本 */

    --destructive: 0 84.2% 60.2%;       /* 红色(用于破坏性操作) */
    --destructive-foreground: 0 0% 98%; /* 破坏性元素上的浅色文本 */

    --border: 189 0% 45%;               /* 中灰色(边框/线条) */
    --input: 155 0% 45%;                /* 灰色输入边框 */
    --ring: 0 0% 0%;                    /* 黑色焦点环 */

    --radius: 0.5rem;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }
}
body {
  font-family: Arial, Helvetica, sans-serif;
}

@layer base {
  * {
    @apply border-border outline-ring/50;
  }
  body {
    @apply bg-background text-foreground;
  }
}

4. 组件文件

创建以下组件文件并粘贴相应的代码:

AddAssignmentDialog.tsx文件:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
"use client"

import type React from "react"

import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import { Assignment } from "@/types"

interface AddAssignmentDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  onAddAssignment: (data: Assignment) => void
  creatorEmail: string
}

export function AddAssignmentDialog({ open, onOpenChange, onAddAssignment, creatorEmail }: AddAssignmentDialogProps) {
  const [title, setTitle] = useState("")
  const [subject, setSubject] = useState("")
  const [teacher, setTeacher] = useState("")
  const [className, setClassName] = useState("")
  const [dueDate, setDueDate] = useState("")

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    // 通常您会将此数据发送到后端
    const newAssignment = { title, subject, teacher, className, dueDate, creatorEmail }
    onAddAssignment(newAssignment)
    console.log("New assignment:", { title, subject, class: className, dueDate, creatorEmail })
    onOpenChange(false)
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>添加新作业</DialogTitle>
          <DialogDescription>
            在此输入新作业的详细信息。完成后点击保存。
          </DialogDescription>
        </DialogHeader>
        <form onSubmit={handleSubmit}>
          <div className="grid gap-4 py-4">
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="title" className="text-right">
                标题
              </Label>
              <Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} className="col-span-3" />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="subject" className="text-right">
                科目
              </Label>
              <Input id="subject" value={subject} onChange={(e) => setSubject(e.target.value)} className="col-span-3" />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="teacher" className="text-right">
                教师
              </Label>
              <Input id="teacher" value={teacher} onChange={(e) => setTeacher(e.target.value)} className="col-span-3" />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="class" className="text-right">
                班级
              </Label>
              <Input
                id="class"
                value={className}
                onChange={(e) => setClassName(e.target.value)}
                className="col-span-3"
              />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="dueDate" className="text-right">
                截止日期
              </Label>
              <Input
                id="dueDate"
                type="date"
                value={dueDate}
                onChange={(e) => setDueDate(e.target.value)}
                className="col-span-3"
              />
            </div>
          </div>
          <DialogFooter>
            <Button type="submit">保存更改</Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  )
}

此文件定义了一个React组件AddAssignmentDialog,它渲染一个对话框表单用于添加新作业。它使用useState管理表单状态,并通过onAddAssignment属性将作业数据提交给父组件。对话框包括标题、科目、教师、班级和截止日期的输入字段,并在提交后关闭。

AddStudentDialog.tsx文件:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
'use client'

import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Student } from '@/types'

interface AddStudentDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  onAddStudent: (data: Student) => void
  loading: boolean
  creatorEmail: string
}

export function AddStudentDialog({ open, onOpenChange, onAddStudent, loading, creatorEmail }: AddStudentDialogProps) {
  const [firstName, setFirstName] = useState('')
  const [lastName, setLastName] = useState('')
  const [className, setClassName] = useState('')
  const [gender, setGender] = useState('')
  const [age, setAge] = useState("")

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    // 通常您会将此数据发送到后端
    onAddStudent({
      firstName,
      lastName,
      className,
      gender,
      age: Number(age),
      creatorEmail
    })
    console.log('New student:', { firstName, lastName, className, gender, age })
    onOpenChange(false)
  }

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>添加新学生</DialogTitle>
          <DialogDescription>
            在此输入新学生的详细信息。完成后点击保存。
          </DialogDescription>
        </DialogHeader>
        <form onSubmit={handleSubmit}>
          <div className="grid gap-4 py-4">
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="firstName" className="text-right">
                名字
              </Label>
              <Input
                id="firstName"
                value={firstName}
                onChange={(e) => setFirstName(e.target.value)}
                className="col-span-3"
              />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="lastName" className="text-right">
                姓氏
              </Label>
              <Input
                id="lastName"
                value={lastName}
                onChange={(e) => setLastName(e.target.value)}
                className="col-span-3"
              />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="class" className="text-right">
                班级
              </Label>
              <Input
                id="class"
                value={className}
                onChange={(e) => setClassName(e.target.value)}
                className="col-span-3"
              />
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="gender" className="text-right">
                性别
              </Label>
              <Select onValueChange={setGender} value={gender}>
                <SelectTrigger className="col-span-3">
                  <SelectValue placeholder="选择性别" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="boy">男孩</SelectItem>
                  <SelectItem value="girl">女孩</SelectItem>
                </SelectContent>
              </Select>
            </div>
            <div className="grid grid-cols-4 items-center gap-4">
              <Label htmlFor="age" className="text-right">
                年龄
              </Label>
              <Input
                id="age"
                type="number"
                step="0.1"
                value={age}
                min={"4"}
                max={"99"}
                placeholder='输入有效年龄'
                onChange={(e) => setAge(e.target.value)}
                className="col-span-3"
              />
            </div>
          </div>
          <DialogFooter>
            <Button disabled={loading} type="submit">{loading ? "保存中..." : "保存更改"}</Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  )
}

此文件定义了一个React组件AddStudentDialog,它渲染一个对话框表单用于添加新学生。它使用useState管理表单状态,并通过onAddStudent属性将学生数据提交给父组件。对话框包括名字、姓氏、班级、性别(带下拉菜单)和年龄的输入字段,并在提交期间处理加载状态。

AssignmentsTable.tsx文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { AssignmentsTable } from "@/types"

export function AssignmentsTables({ assignments }: { assignments: AssignmentsTable[] }) {
  console.log("Assignments", assignments)

  return (
    <Table>
      <TableCaption>最近作业列表。</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>标题</TableHead>
          <TableHead>科目</TableHead>
          <TableHead>班级</TableHead>
          <TableHead>教师</TableHead>
          <TableHead>截止日期</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {assignments.map((assignment) => (
          <TableRow key={assignment.$id}>
            <TableCell>{assignment.title}</TableCell>
            <TableCell>{assignment.subject}</TableCell>
            <TableCell>{assignment.className}</TableCell>
            <TableCell>{assignment.teacher}</TableCell>
            <TableCell>{assignment.dueDate}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

此文件定义了一个React组件AssignmentsTables,它渲染一个表格来显示作业列表。它接受一个作业数组作为属性,并遍历它们以填充表格行,包含标题、科目、班级、教师和截止日期等详细信息。表格包括标题和表头以提高可读性。

auth-layout.tsx文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import type React from "react"

interface AuthLayoutProps {
    children: React.ReactNode
    title: string
    description?: string
}

export function AuthLayout({ children, title, description }: AuthLayoutProps) {
    return (
        <div className="min-h-screen grid lg:grid-cols-2">
            {/* 左侧 - 表单 */}
            <div className="flex items-center justify-center p-8">
                <div className="mx-auto w-full max-w-sm space-y-6">
                    <div className="space-y-2 text-center">
                        <h1 className="text-3xl font-bold tracking-tight">{title}</h1>
                        {description && <p className="text-sm text-muted-foreground">{description}</p>}
                    </div>
                    {children}
                </div>
            </div>

            {/* 右侧 - 图片 */}
            <div className="hidden lg:block relative bg-black">
                <div className="absolute inset-0 bg-[url('https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-xOOAKcDxPyvxlDygdNGtUvjEA6QHBO.png')] bg-cover bg-center opacity-50" />
                <div className="relative h-full flex items-center justify-center text-white p-12">
                    <div className="space-y-6 max-w-lg">
                        <h2 className="text-4xl font-bold">保持您孩子的成功</h2>
                        <p className="text-lg text-gray-200">
                            与教师联系,跟踪进度,并参与您孩子的教育旅程。
                        </p>
                    </div>
                </div>
            </div>
        </div>
    )
}

此文件定义了一个React组件AuthLayout,它为认证页面提供布局。它包括左侧的表单(带标题和可选描述)和右侧的背景图片和激励性文字。该布局是响应式的,在较小屏幕上隐藏图片。

Features.tsx文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { Book, BarChart, MessageCircle } from "lucide-react"

const features = [
  {
    name: "综合仪表板",
    description: "查看学生的整体学业表现,包括平均成绩和随时间推移的进度。",
    icon: BarChart,
  },
  {
    name: "轻松沟通",
    description: "学校管理员和教师之间的直接消息系统,实现快速高效的沟通。",
    icon: MessageCircle,
  },
  {
    name: "学业跟踪",
    description:
      "监控作业、即将到来的考试和项目截止日期,帮助您的学生掌握学习进度。",
    icon: Book,
  },
]

export function Features() {
  return (
    <div className="py-12 bg-white" id="features">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="lg:text-center">
          <h2 className="text-base text-primary font-semibold tracking-wide uppercase">功能</h2>
          <p className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl">
            保持联系所需的一切
          </p>
          <p className="mt-4 max-w-2xl text-xl text-gray-500 lg:
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计