用 AI 结对编程从 0 搭一个"单词后台管理系统":Next.js + Supabase + Drizzle + shadcn/ui 全记录
今天用 Claude Code(AI 编程助手)从零搭了一个单词管理后台。这篇文章不是贴代码清单,而是把今天踩过的每一个技术点,从底层原理讲明白:为什么用云数据库?ORM 到底在做什么?密码为什么不能明文存?localStorage 为什么要放在 effect 里读? 每一段代码都对应一个笔记里的知识点。
一、这个项目要做什么?
我起的项目名叫 danci,是一个 单词学习 App 的完整闭环:
- 后台管理系统:给"小编管理员"维护单词书(增删改查)、管理管理员账号
- H5 应用:后续给普通用户背单词用
- 多端开发:一套代码跑 Web / H5 / 后续小程序
今天完成的是第一阶段:后台管理系统的前端 UI + 认证系统 + 数据层骨架。
核心亮点,也是这篇文章的主线:
- 数据清洗(从 GitHub 高星单词库选型、格式化、审核)
- Supabase ------ 云端 PostgreSQL(BaaS,Backend as a Service)
- Drizzle ORM ------ 不写 SQL,用对象操作数据库
- shadcn/ui ------ 高定制度的组件库
二、为什么"不重复造轮子"?聊聊组件库选型
笔记里有句话: "80% 前端组件业务趋同,不用重复造轮子。"
做后台管理系统,你需要的其实就那几样:表格、表单、弹窗、按钮、侧边栏。这些每家公司长得都差不多。所以选组件库是常态,不是偷懒。
市面上三巨头对比:
| 组件库 | 特点 | 适合场景 |
|---|---|---|
| Element UI / Ant Design | 大而全,开箱即用,风格统一 | 中后台快速交付,团队大、要求一致 |
| shadcn/ui | 不是"组件库"而是"组件源码" | 需要高度定制、代码可控、想学原理 |
为什么选 shadcn/ui?
它的核心思想是:不给你打包好的黑盒组件,而是把组件源码直接复制到你的项目里(components/ui 目录),随你改。
- 定制性极好:整个文件都是你的,改一个样式就是改一个文件
- 配合 Tailwind CSS :用原子类(utility class)拼界面,
flex、p-4、text-sm这种语义化类名堆起来,可读性极强,对 AI 也友好(AI 读得懂 Tailwind 类名,能直接照着写) - 按需加载:用到哪个组件就装哪个,不会像 Antd 那样整包引入
项目里的 components/ui/ 目录下,就是 shadcn 生成的 button.tsx、dialog.tsx、table.tsx...... 全部开源可改。
三、为什么用云数据库?------ BaaS 的底层逻辑
以前做项目,数据库这一步最痛苦:自己买服务器、装 PostgreSQL、配安全组、做备份、扩连接数......每一项都是运维成本。
笔记里写的:Supabase 是 BaaS(Backend as a Service),性能、安全、可扩展性、部署成本几乎为 0。
Supabase 本质是:
- 托管了一整套 PostgreSQL 云数据库(PostgreSQL 简称 psql,最流行的关系型数据库)
- 在上面又加了一层 向量数据库 能力(
pgvector),以后做"相似单词推荐""AI 记忆复习"可以直接用 - 提供鉴权、实时订阅、存储等后端能力,全托管
所以我们只在 .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;
注意两点:
- 表名带连字符
admin-users,所以 SQL 里全程加双引号(否则会被当成减法运算) - 末尾那条
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 + 数据库生态里很常见,值得记下来。
五、认证系统:从密码哈希到会话 Cookie
这是今天信息量最大的一块。核心问题: "这个请求的人,是谁?" 回答这个问题,需要三步:注册、登录、会话校验。
5.1 密码为什么不安全?------ 哈希加盐
铁律:数据库里绝不能存明文密码。 一旦数据库泄露,等于把所有用户账号拱手送人。正确做法是存哈希值。
看 lib/auth.ts 的密码处理,用的是 Node 内置 crypto 的 scrypt 算法:
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。
流程:
- 登录成功 → 服务端生成一个随机
token,存进数据库会话表,同时通过Set-Cookie把 token 塞给浏览器 - 浏览器后续每个请求自动带上这个 Cookie
- 服务端拿 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.ts 用 Zod 定义校验规则,一套 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 是一个标准的前端数据流范本:
- State :
books列表、搜索关键词query、弹窗开关、正在编辑/删除的书 - useMemo 派生数据 :
filtered(搜索过滤后的列表)、totalWords(词汇总量统计)------ 这些数据从 state 计算而来,不需要单独存一份 state - UI 交互 :新增/编辑共用一个
BookDialog,删除用DeleteBookDialog二次确认,所有操作结果用sonner的toast给用户即时反馈
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
下一步规划:
- 建
words表,跑数据清洗脚本导入 GitHub 单词库 - books 表从 localStorage 换成真数据库
- 做 H5 背单词端
- 上向量检索做智能复习
用 AI 结对编程的感觉是:AI 把"怎么写"的体力活干掉了,人把精力放在"为什么这么设计"上------选型、安全、架构取舍,这些恰恰是这篇文章想讲清楚的东西。
如果你也在做类似的全栈练手项目,欢迎在评论区交流你的技术选型。🚀