用 AI 结对编程从 0 搭一个"单词后台管理系统":Next.js + Supabase + Drizzle + shadcn/ui 全记录

用 AI 结对编程从 0 搭一个"单词后台管理系统":Next.js + Supabase + Drizzle + shadcn/ui 全记录

今天用 Claude Code(AI 编程助手)从零搭了一个单词管理后台。这篇文章不是贴代码清单,而是把今天踩过的每一个技术点,从底层原理讲明白:为什么用云数据库?ORM 到底在做什么?密码为什么不能明文存?localStorage 为什么要放在 effect 里读? 每一段代码都对应一个笔记里的知识点。


一、这个项目要做什么?

我起的项目名叫 danci,是一个 单词学习 App 的完整闭环:

  • 后台管理系统:给"小编管理员"维护单词书(增删改查)、管理管理员账号
  • H5 应用:后续给普通用户背单词用
  • 多端开发:一套代码跑 Web / H5 / 后续小程序

今天完成的是第一阶段:后台管理系统的前端 UI + 认证系统 + 数据层骨架

核心亮点,也是这篇文章的主线:

  1. 数据清洗(从 GitHub 高星单词库选型、格式化、审核)
  2. Supabase ------ 云端 PostgreSQL(BaaS,Backend as a Service)
  3. Drizzle ORM ------ 不写 SQL,用对象操作数据库
  4. shadcn/ui ------ 高定制度的组件库

二、为什么"不重复造轮子"?聊聊组件库选型

笔记里有句话: "80% 前端组件业务趋同,不用重复造轮子。"

做后台管理系统,你需要的其实就那几样:表格、表单、弹窗、按钮、侧边栏。这些每家公司长得都差不多。所以选组件库是常态,不是偷懒。

市面上三巨头对比:

组件库 特点 适合场景
Element UI / Ant Design 大而全,开箱即用,风格统一 中后台快速交付,团队大、要求一致
shadcn/ui 不是"组件库"而是"组件源码" 需要高度定制、代码可控、想学原理

为什么选 shadcn/ui

它的核心思想是:不给你打包好的黑盒组件,而是把组件源码直接复制到你的项目里(components/ui 目录),随你改。

  • 定制性极好:整个文件都是你的,改一个样式就是改一个文件
  • 配合 Tailwind CSS :用原子类(utility class)拼界面,flexp-4text-sm 这种语义化类名堆起来,可读性极强,对 AI 也友好(AI 读得懂 Tailwind 类名,能直接照着写)
  • 按需加载:用到哪个组件就装哪个,不会像 Antd 那样整包引入

项目里的 components/ui/ 目录下,就是 shadcn 生成的 button.tsxdialog.tsxtable.tsx...... 全部开源可改。


三、为什么用云数据库?------ BaaS 的底层逻辑

以前做项目,数据库这一步最痛苦:自己买服务器、装 PostgreSQL、配安全组、做备份、扩连接数......每一项都是运维成本。

笔记里写的:Supabase 是 BaaS(Backend as a Service),性能、安全、可扩展性、部署成本几乎为 0。

Supabase 本质是:

  1. 托管了一整套 PostgreSQL 云数据库(PostgreSQL 简称 psql,最流行的关系型数据库)
  2. 在上面又加了一层 向量数据库 能力(pgvector),以后做"相似单词推荐""AI 记忆复习"可以直接用
  3. 提供鉴权、实时订阅、存储等后端能力,全托管

所以我们只在 .env 里配一行 DATABASE_URL 连接串,数据库就"云端就绪"了:

bash 复制代码
DATABASE_URL=postgresql://postgres:xxxx@db.xxxx.supabase.co:5432/postgres

关系型数据库 简单理解:数据是"一张张表格",每张表有行(一条记录)有列(一个字段),表之间还能用外键互相引用。比如"管理员表"和"会话表",就用外键关联。


四、ORM:把"表格"翻译成"对象"

这是今天最重要的知识点之一。

4.1 没有 ORM 的世界

你要在数据库里存一个管理员,得写原生 SQL:

sql 复制代码
INSERT INTO "admin-users" (name, email, password_hash, role)
VALUES ('小明', 'ming@example.com', 'abc123', 'admin');

插入完想查,又写一长串 SELECT。数据一多,全是字符串拼接,拼错一个引号,SQL 注入漏洞就来了。而且数据库字段和 JS 变量之间没有对应关系,数据库字段改了,代码里全得跟着改。

4.2 ORM 的世界

ORM(Object-Relational Mapping,对象关系映射)做的事情,就是笔记里那句精髓:

"对象和数据库一行记录对应起来" ------ todo.save() 就等于执行了一条 INSERT INTO todo ...

你用 JS/TS 定义一个"类",这个类对应一张表;类的实例对应表里的一行;调用 .save().get() 之类的方法,ORM 在背后翻译成 SQL。

4.3 看看 Drizzle 怎么定义表

Drizzle 是 ORM 家族里比较现代的一个,特点是:TypeScript 类型直接由表定义推导,类型安全拉满。

看我们项目的 lib/schema.ts

scss 复制代码
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";

// 管理员表:保存普通管理员与系统管理员。
export const adminUsers = pgTable("admin-users", {
  id: uuid("id").defaultRandom().primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  passwordHash: text("password_hash").notNull(),
  role: text("role", { enum: ["super_admin", "admin"] })
    .notNull()
    .default("admin"),
  createdAt: timestamp("created_at", { withTimezone: true })
    .defaultNow()
    .notNull(),
  updatedAt: timestamp("updated_at", { withTimezone: true })
    .defaultNow()
    .notNull(),
});

一行行拆开讲:

  • pgTable("admin-users", {...}):定义一张叫 admin-users 的表
  • id: uuid("id").defaultRandom().primaryKey():主键,uuid 类型,数据库自动生成随机值。主键 = 每一行的唯一身份证
  • email: text("email").notNull().unique():邮箱,非空且唯一(不能有两个同名邮箱)
  • role: text("role", { enum: ["super_admin", "admin"] }).default("admin"):角色字段,只能取两个值之一,默认是普通管理员
  • defaultNow():插入时数据库自动填当前时间

注意一个细节 :这里写的是 passwordHash,SQL 列名是 password_hash。这就是 ORM 的列名映射能力 ------ 前端代码用驼峰,数据库用下划线,两边都舒服。

再看会话表 ,它演示了 ORM 最核心的能力之一 ------ 外键关联

css 复制代码
export const adminSessions = pgTable("admin-session", {
  id: uuid("id").defaultRandom().primaryKey(),
  token: text("token").notNull().unique(),
  userId: uuid("user_id")
    .notNull()
    .references(() => adminUsers.id, { onDelete: "cascade" }),
  expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
  createdAt: timestamp("created_at", { withTimezone: true })
    .defaultNow()
    .notNull(),
});

references(() => adminUsers.id, { onDelete: "cascade" }) 意思是:这条会话记录属于哪个管理员onDelete: "cascade"(级联删除)------ 管理员被删了,他名下所有会话自动一起删,不会留一堆孤儿数据。这就是关系型数据库和外键的威力。

4.4 "建表"这个动作,交给迁移文件

关键点:用 Drizzle 我们不是手动去数据库建表,而是定义 schema,然后跑迁移命令,让它自动生成建表 SQL 并执行。

笔记里列的那几个脚本,对应 package.json:

json 复制代码
"db:generate": "drizzle-kit generate",  // 对比 schema 变化,生成迁移文件
"db:migrate": "drizzle-kit migrate",    // 执行迁移
"db:push": "drizzle-kit push",          // 直接把 schema 推到云端数据库
"db:studio": "drizzle-kit studio"       // 数据库可视化工具
  • generate:像 git diff 一样对比"上次的表结构"和"现在的 schema",差异生成一个迁移文件
  • migrate / push :把迁移应用到数据库。push 对开发期特别爽,改完 schema 一条命令就同步到 Supabase 云端
  • studio:起一个本地网页,可视化浏览数据库,相当于免安装的 DBeaver/phpMyAdmin

看自动生成的迁移文件 drizzle/0000_wise_quasar.sql,这就是 SQL 的本来面目,也是上面 TS 定义被翻译后的结果:

sql 复制代码
CREATE TABLE "admin-session" (
    "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
    "token" text NOT NULL,
    "user_id" uuid NOT NULL,
    "expires_at" timestamp with time zone NOT NULL,
    ...
);
--> statement-breakpoint
CREATE TABLE "admin-users" ( ... );

ALTER TABLE "admin-session" ADD CONSTRAINT
  "admin-session_user_id_admin-users_id_fk"
  FOREIGN KEY ("user_id") REFERENCES "public"."admin-users"("id")
  ON DELETE cascade ON UPDATE no action;

注意两点:

  1. 表名带连字符 admin-users,所以 SQL 里全程加双引号(否则会被当成减法运算)
  2. 末尾那条 ALTER TABLE 就是 外键 在 SQL 层面的真身 ------ 这正是 references(...) 在背后做的事

所以整条链路是: TS 定义表 → drizzle-kit generate 生成 SQL 迁移 → drizzle-kit push 推给 Supabase → 云端 psql 里真的建出两张表。你从头到尾没手写过一行建表 SQL。

4.5 数据库连接与一个坑:HMR 连接池

lib/db.ts 是整个数据层的入口:

typescript 复制代码
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "@/lib/schema";

const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
  throw new Error("DATABASE_URL is not set");
}

// Reuse the postgres.js client across HMR reloads in development
const globalForDb = globalThis as unknown as {
  client?: ReturnType<typeof postgres>;
};

const client = globalForDb.client ?? postgres(connectionString, { ssl: false });

if (process.env.NODE_ENV !== "production") {
  globalForDb.client = client;
}

export const db = drizzle({ client, schema });

这里藏着一个非常实战的坑。 Next.js 开发模式有 HMR(热模块替换) :你改一行代码,模块会被重新加载,但 Node 进程不重启。如果每次模块重载都 postgres(connectionString) 新建一个连接池,改几行代码就消耗一个连接,Supabase 免费版的连接数上限很快就爆了,报错"connection limit exceeded"。

解决方案:把连接池挂到 globalThis(全局对象)上,模块重载时先看全局有没有,有就直接复用。这个 globalForDb 模式在整个 Next.js + 数据库生态里很常见,值得记下来。


这是今天信息量最大的一块。核心问题: "这个请求的人,是谁?" 回答这个问题,需要三步:注册、登录、会话校验。

5.1 密码为什么不安全?------ 哈希加盐

铁律:数据库里绝不能存明文密码。 一旦数据库泄露,等于把所有用户账号拱手送人。正确做法是存哈希值

lib/auth.ts 的密码处理,用的是 Node 内置 cryptoscrypt 算法:

typescript 复制代码
async function hashPassword(password: string): Promise<string> {
  const salt = randomBytes(16).toString("hex");
  const derived = (await scryptAsync(password, salt, 64)) as Buffer;
  return `${salt}:${derived.toString("hex")}`;
}

什么是哈希(Hash)? 一个单向函数:输入"123456",输出一串固定长度、看起来乱码的字符串,而且几乎不可能从输出反推出输入。数据库里只存这串乱码,黑客拿到也还原不出密码。

为什么还要加盐(salt)? 因为"123456"这种弱密码,全世界几亿人都在用,同一个密码哈希出来的结果是一样的。黑客预先把几亿个常见密码的哈希值做成一张"彩虹表",一查就知道你密码是啥。 就是一段随机数据,跟密码混在一起再哈希,这样同一个密码,加不同的盐,哈希结果完全不同。存储格式 盐:哈希,登录时用存的盐重新算一遍再比对。

验证用 timingSafeEqual(时间安全比较),防止"计时攻击"------用固定耗时比较,避免黑客通过响应时间差异猜出密码位数的信息。

5.2 会话:Cookie 是"会员卡"

HTTP 是无状态的 :服务端处理完一个请求就"失忆"了。那怎么记住"你已登录"?------Cookie

流程:

  1. 登录成功 → 服务端生成一个随机 token,存进数据库会话表,同时通过 Set-Cookie 把 token 塞给浏览器
  2. 浏览器后续每个请求自动带上这个 Cookie
  3. 服务端拿 token 去会话表查,查到且未过期 → 认出来你是谁

setSessionCookie 的关键参数:

arduino 复制代码
store.set(SESSION_COOKIE, token, {
  httpOnly: true,                                  // 浏览器 JS 读不到,防 XSS 窃取
  secure: process.env.NODE_ENV === "production",   // 生产环境强制 HTTPS
  sameSite: "lax",                                 // 防 CSRF
  path: "/",
  maxAge: 7 * 24 * 60 * 60,                        // 7 天有效期
});
  • httpOnly:Cookie 只能由浏览器网络层自动携带,页面里的 JS 拿不到,就算被注入恶意脚本也偷不走
  • sameSite: "lax" :限制跨站请求携带 Cookie,缓解 CSRF(跨站请求伪造)攻击
  • maxAge:7 天过期

验证会话时,用 SQL 的 JOIN 把会话表和用户表联起来,一次查出"这个 token 对应的用户是谁",同时判断有没有过期:

perl 复制代码
const rows = await db
  .select({ user: adminUsers })
  .from(adminSessions)
  .innerJoin(adminUsers, eq(adminSessions.userId, adminUsers.id))
  .where(
    and(
      eq(adminSessions.token, token),
      gt(adminSessions.expiresAt, new Date())
    )
  )
  .limit(1);

这段就是 ORM 生成 SELECT ... FROM admin-session INNER JOIN admin-users ON ... WHERE token=? AND expires_at > now() 的过程,一个 innerJoin 就省掉手写一大段 JOIN SQL。

5.3 第一个管理员:注册即超级管理员

业务规则:这个系统只能有一个超级管理员(超管),由第一个注册的人担任。 之后超管可以添加普通管理员。

这个规则在路由 app/api/auth/signup/route.ts 里强制执行:

ini 复制代码
// 仅允许首个系统管理员注册;已有管理员则禁止二次注册。
const count = await getAdminCount();
if (count > 0) {
  return NextResponse.json(
    { error: "已有管理员,不允许重复注册" },
    { status: 403 }
  );
}

const user = await createAdmin({ ...parsed.data, role: "super_admin" });
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ user });

并且页面上也做了引导:signin 会先查管理员数量,一个管理员都没有 → 直接重定向去注册 ;注册页查数量,已有管理员 → 跳回登录页。服务端 + 页面双层校验,前端拦不住的东西,后端再兜一层。

5.4 路由守卫:管理页面不是谁都能进

app/(admin)/layout.tsx 是管理后台所有页面的"门卫":

ini 复制代码
const { status, user } = useAuth();

useEffect(() => {
  if (status === "unauthenticated") router.replace("/signin");
}, [status, router]);

if (status !== "authenticated" || !user) {
  return (
    <div className="flex min-h-svh items-center justify-center">
      <Skeleton className="h-10 w-64" />
    </div>
  );
}

未登录就重定向到登录页;还没验证完(loading)时渲染骨架屏(Skeleton) ,防止页面内容闪一下再被踢走。用户感受是:要么正常显示,要么瞬间被带去登录页,不会看到内容闪现。

5.5 数据校验:前端 Zod,后端也要 Zod

lib/validation.tsZod 定义校验规则,一套 Schema 前后端复用:

javascript 复制代码
export const signUpSchema = z
  .object({
    name: nameSchema,
    email: emailSchema,               // z.string().email()
    password: passwordSchema,         // 至少 6 位
    confirmPassword: passwordSchema,
  })
  .refine((d) => d.password === d.confirmPassword, {
    message: "两次输入的密码不一致",
    path: ["confirmPassword"],
  });

前端表单(React Hook Form + zodResolver)用它做输入时即时校验 ;后端 API 用同一个 Schema 的 .safeParse()提交时最终校验永远不要只信前端的校验------用户可以绕过前端直接调 API,所以后端必须再验一遍。这就是"纵深防御"。


六、单词书管理:Mock 数据层 + 页面

6.1 一个务实的取舍:先 localStorage,后接数据库

数据库的 books 表还没建,所以这阶段 lib/books.ts 先用 localStorage 做 Mock 数据层 :数据存浏览器本地,首次访问自动播种(seed)5 本示例单词书。这样 UI 开发不被后端阻塞。等 books 表建好,只需要把 getBooks()createBook() 的实现换成 Drizzle 查库,接口签名不变,页面一行都不用改------这就是抽象的价值。

注意这里面的 hydration(水合)陷阱,注释写得很明白:

scss 复制代码
useEffect(() => {
  // localStorage 仅在客户端可用,必须在 effect 中读取(hydration 安全)。
  setBooks(getBooks());
}, []);

Next.js 页面先在服务端渲染成 HTML(SSR),再在浏览器"水合"成交互页面。服务端没有 window/localStorage ,如果直接在组件函数体里读 localStorage,服务端渲染那一步就崩了。所以必须放到 useEffect 里,等浏览器环境就绪再读。

6.2 页面的数据流

books/page.tsx 是一个标准的前端数据流范本:

  1. Statebooks 列表、搜索关键词 query、弹窗开关、正在编辑/删除的书
  2. useMemo 派生数据filtered(搜索过滤后的列表)、totalWords(词汇总量统计)------ 这些数据从 state 计算而来,不需要单独存一份 state
  3. UI 交互 :新增/编辑共用一个 BookDialog,删除用 DeleteBookDialog 二次确认,所有操作结果用 sonnertoast 给用户即时反馈
markdown 复制代码
books ──▶ useMemo ──▶ filtered(表格展示)
    └────▶ useMemo ──▶ totalWords(统计卡片)

6.3 侧边栏的权限控制

app-sidebar.tsx 里演示了权限控制 最直观的写法 ------ 菜单项带个 superAdminOnly 标记,渲染时过滤:

javascript 复制代码
const NAV_ITEMS = [
  { title: "单词书管理", href: "/books", icon: BookOpen },
  { title: "管理员管理", href: "/admin-users", icon: Users, superAdminOnly: true },
];

// 渲染时:非超管看不到"管理员管理"入口
{NAV_ITEMS
  .filter((item) => !item.superAdminOnly || user.role === "super_admin")
  .map((item) => ( /* 渲染菜单项 */ ))}

注意:这层隐藏只是 UI 上的,真正的权限校验还得在服务端 API/路由再做一次,防止超管接口被普通管理员直接调用。


七、数据清洗:从 GitHub 单词库到数据库

README 里最后一部分很有意思:从 GitHub 高星单词资料库下载了一个 zip,里面有 138KB 的 JSON 单词数据。

需求:把 138KB 的 JSON 变成一张 words 表能用的数据。

这里有两条路:

路线 A:让 AI 直接处理数据 ------ 把 138KB JSON 塞进 AI 上下文,让它转成 CSV/SQL。138KB 大概是几万 token,成本高、还可能截断。

路线 B(实际选的):让 AI 写转换脚本 ------ 只让 AI 读 1000 token 的样例结构,写一个几十行的格式转换脚本(JSON → CSV),本地运行脚本完成转换,再导入数据库。

这就是数据清洗(Data Cleaning)的三步:选择 (挑字段)、格式化 (统一结构)、审核(人工/AI 检查质量)。现在 GitHub 上这种高星资料库,质量参差不齐,同义词、拼写错误、重复词条都需要清洗后才敢进生产数据库。


八、用"约定式提交"给代码做版本管理

最后,今天两个 commit 都遵循了 **Conventional Commits(约定式提交)**规范 ------ 这是目前最主流的 Git 提交风格:

makefile 复制代码
feat:完成后台管理系统前端UI开发

格式:<type>(可选scope): <描述>

类型 含义
feat 新功能
fix 修 Bug
docs 只改文档
refactor 重构
perf 性能优化
test 改测试
chore 构建/工具变动
revert 回退

好处:git log 一眼看出每次提交的性质,可以自动生成 changelog,还能按类型过滤历史(git log --grep=feat)。而且现在的 Coding Agent 都内置了提交能力,你只要约定好规范,提交信息自动符合标准。


九、小结与下一步

今天完成的地图:

bash 复制代码
┌─ 前端层:Next.js App Router + shadcn/ui + Tailwind + React Hook Form + Zod
│    ├─ 认证页(注册/登录,超管机制)
│    ├─ 管理后台(单词书管理 UI + 侧边栏权限)
│    └─ AuthProvider(全局登录态)
├─ 数据层:Drizzle ORM + postgres.js + Supabase(psql 云数据库)
│    ├─ schema.ts 定义两张表
│    ├─ 迁移文件自动生成 + push 上云
│    └─ books 暂用 localStorage Mock,接口预留
└─ 工程规范:Conventional Commits + ESLint + AGENTS.md

下一步规划

  1. words 表,跑数据清洗脚本导入 GitHub 单词库
  2. books 表从 localStorage 换成真数据库
  3. 做 H5 背单词端
  4. 上向量检索做智能复习

用 AI 结对编程的感觉是:AI 把"怎么写"的体力活干掉了,人把精力放在"为什么这么设计"上------选型、安全、架构取舍,这些恰恰是这篇文章想讲清楚的东西。

如果你也在做类似的全栈练手项目,欢迎在评论区交流你的技术选型。🚀

相关推荐
爱学习的小邓同学1 小时前
Golang --- (1)第一个Golang程序
开发语言·后端·golang
小灰灰搞电子2 小时前
Rust+Slint 实现的“DNA双螺旋”加载动画源码分享
后端·rust·slint·加载动画
面向Google编程2 小时前
向量库不再囤数据:Milvus 3.0 零拷贝直读数据湖
后端
IT_陈寒4 小时前
Redis内存警告竟是因为这个不起眼的配置项
前端·人工智能·后端
Python私教4 小时前
AI 漫剧角色一进分镜就变脸?把提示词升级成“角色 ID + 镜头合同”
后端
Python私教4 小时前
一次生成 20 个角色却全都撞脸:我用“角色合同”重做了批量生成流程
后端
用户594404103565 小时前
Go 语言高性能 Web 服务开发:基于 Gin + GORM + Redis 构建 RESTful API
后端
Python私教5 小时前
App 第一版该砍什么?用三问法守住 MVP 的工程底线
后端·mvp
ServBay5 小时前
AI 工程师必备的 9 个 Python 库,从数据验证到模型优化
后端·python·ai编程