年初的时候,我把团队内部的任务看板彻底重写了一遍,项目代号就叫 t3code。这个从零开始的 Web 项目,从立项到上线大概用了三周时间,核心目标就两个:整个技术栈必须端到端类型安全,新成员能在两天内上手改需求。选择 Next.js + TypeScript + Tailwind,再配合 tRPC 和 Prisma 这套 T3 Stack 组合之后,我发现这两件事确实可以同时成立。如果你正在犹豫要不要引入这套技术栈,或者已经上了 T3 Stack 但卡在认证、路由、数据库模型怎么串起来这一个环节,这篇博文是我踩过坑之后的完整实操记录,配置和代码可以直接抄走。
1. 项目立项:为什么 t3code 选择了 T3 Stack
1.1 项目背景:团队任务工具到底缺什么
先说需求来源。我们团队之前用通用项目管理工具,大而全的功能铺了一屏,实际每天打开就只为了盯三四件事的进度。表格工具倒是轻,但没有状态流转,没有负责人概念,评论分散在聊天软件里,根本串不起来。市面上看板工具不少,可要么按人头收费贵得离谱,要么数据全在别人服务器上,二次开发基本没门。讨论到最后,大家一致想要一个极度轻量的内部任务板:有列表、有看板、能拖拽、能评论,外加一个团队代码片段库,方便把常用的脚本和组件片段沉淀下来。
这个小工具必须解决三个问题:第一,看板上的卡片状态要实时可见,不能出现一个人改了另外几个看不到的情况;第二,任务和代码片段都要有标签、有负责人,能快速检索;第三,整个东西部署简单,三个开发人员自己维护得起。于是 t3code 就这么立项了,名字取“T3 Stack codebase”的意思,也算点明技术路线。
1.2 为什么是 T3 Stack,而不是“全都要”
说实话,项目初期我认真考虑过几种方案。用 Node.js + Express + 原生 React 是稳妥,但接口文档、参数校验、数据库迁移这些事全得自己搭,三周时间根本不够。用 Spring Boot 那一套对团队里的前端工程师也不友好,光是 Java 环境就能劝退一半人。后来我们翻到 T3 Stack,一句话说清楚它的哲学:不在技术选型上炫技,只把最成熟的工具以最优方式组合起来。
T3 Stack 的核心组成是 Next.js、TypeScript、Tailwind CSS,然后按需挂上 Prisma、tRPC、NextAuth.js。Next.js 负责页面渲染和路由,TypeScript 负责全链路类型约束,Tailwind 负责样式,tRPC 负责接口层,Prisma 负责数据库,NextAuth 负责登录认证。组合之后最直观的变化是:类型定义从数据库表一路传到前端组件,中间没有任何断层。以前改一个后端字段要前后端两个人互相确认,现在后端改了模型,前端类型立刻报错,联调成本降了一个量级。这个收益对三人小团队可能只是舒服,对稍微大一点的团队简直就是止损神器。
1.3 t3code 的功能蓝图与整体架构
t3code 最终落地的功能是两层。第一层是看板:项目可多建,每个项目下面有任务列表,任务在列表里按状态拖动,状态就是待办、进行中、已完成三列。每张任务卡支持描述、负责人、标签,以及一条时间线评论。第二层是代码片段库:按语言和场景标签分类,支持代码高亮展示和一键复制,还可以直接从某条任务评论里持久化成片段。管理端再配一个成员管理页面,管理员可以添加或者禁用团队成员。
架构上采用 Next.js App Router,页面入口在 src/app,后端逻辑分三层:src/server 放数据库和认证,src/trpc 放类型安全的接口定义,业务代码集中在 src/server/api/routers 里。数据只存 Postgres,Prisma 负责操作。前端通过 tRPC 生成的 hooks 直接调用,比 fetch 封装少写很多样板代码。整套系统只有一个 Node 服务,部署目标选 Vercel,数据库用托管 Postgres,域名挂好 SSL 就能上线,不需要单独维护服务器。
2. 从零搭建:初始化命令与工程骨架
2.1 一条命令生成 T3 工程模板
这里分享一下初始化过程。T3 Stack 官方提供了 create-t3-app 脚手架,一条命令就能拿到标准工程,不用自己拼配置。
npm create t3-app@latest t3code执行之后交互式命令行会问你要选哪些模块。我的选择是:Next.js、TypeScript、Tailwind CSS、Prisma、tRPC、NextAuth.js、ESLint。目录结构初步是标准 T3 布局,src/app 是页面,src/server 是服务端逻辑,src/trpc 是接口定义。这个模板最大的价值是已经把 Prisma、tRPC、NextAuth 三者打通了,你需要做的只是替换成自己的业务表,而不是从头连电路。
生成完项目后,第一件事建议先跑一遍npm run dev,确认模板本身没坏,再去动业务代码。我见过一些人拿到新项目就开始堆代码,结果发现环境依赖有问题,定位了半天也分不清是模板问题还是自己改的问题。先跑通基线,后面出了问题边界才清晰。
2.2 TypeScript 严格模式:让类型检查前置到编译期
create-t3-app 默认开启 TypeScript 严格模式,这一点一定要保留。strict 模式会把“可能存在 null 或 undefined”这类问题直接暴露在开发期,而不是等到运行时崩了再回来查。t3code 里有一张 Task 表,assigneeId 是可选的外键,因为在看板里任务不一定立刻指定负责人。如果不开严格模式,你很容易写出assignee.name这种代码,前端看着没事,运行时直接白屏。开启严格模式后,TypeScript 会强制你处理assigneeId === null的情况,逼着你把边界条件想清楚再动手。
实际操作中,我还给 tsconfig 加了一条"noUncheckedIndexedAccess": true,这样访问数组和 Record 的任意下标都会被标记为可能有 undefined。刚开始会觉得烦,但看板和标签的取值恰恰最容易出现数组越界和键不存在的情况,这一条配置帮我们提前拦截了不少运行时错误。代价是代码里多写几个空值判断,收益是整个项目上线后几乎没有发生过“打开页面白屏”这类低级事故。
2.3 Tailwind 配置:从随便写写变成设计系统
T3 模板集成好了 Tailwind,但这只是起点。我强烈建议你在项目开始前把主题 token 定义清楚,而不是写到哪儿定义到哪儿。t3code 的 tailwind.config.ts 里我扩展了品牌色、状态色、圆角、阴影这几组变量,方便所有页面保持一致。
// tailwind.config.ts import { type Config } from "tailwindcss"; export default { content: ["./src/**/*.{js,ts,jsx,tsx}"], theme: { extend: { colors: { brand: { DEFAULT: "#6366f1", hover: "#4f46e5", }, status: { todo: "#f59e0b", doing: "#3b82f6", done: "#10b981", }, }, borderRadius: { card: "0.75rem", }, }, }, plugins: [], } satisfies Config;组件层的做法是:基础 UI 元素尽量复用函数组件,比如TaskCard就是一个同时承载显示和拖拽的组件,在列表和看板场景里都能用。不要在组件里直接散落bg-blue-500 text-white这种类名,一旦 UI 调整,全站几十处都要跟着改。把状态颜色统一映射到status.todo这类 token 上,改主题的时候只动配置文件,视觉上不会出现漏网之鱼。
2.4 App Router 目录组织:把页面、接口、服务分开
T3 模板里 App Router 的结构值得沿用自己的分层约定。t3code 最终是这样的:
src/ app/ (auth)/signin/ (dashboard)/ projects/ snippets/ api/trpc/[trpc]/ layout.tsx page.tsx components/ task/ project/ snippet/ server/ api/ routers/ root.ts trpc.ts auth.ts db.ts trpc/ react.tsx server.ts lib/ utils.ts把页面放在 src/app,组件放在 src/components,数据库和认证放在 src/server,接口路由放在 src/server/api,这样职责边界非常清楚。前端组件只关心怎么渲染数据,不关心数据从哪来;后端路由只负责校验和数据处理,不关心页面结构。后端暴露接口时注意一个原则:不要在组件里直接 import 数据库客户端,所有数据访问一律走 tRPC,否则缓存、权限控制、日志追踪全部失效。
3. 数据层:Prisma 建模、迁移与连接池
3.1 schema.prisma 建模:三张核心表的设计思路
t3code 的数据库表不算多,核心是 User、Project、Task、TaskComment、Snippet、SnippetTag。这里重点说 Task 和 Snippet,因为它们最体现看板产品的需求。
// prisma/schema.prisma model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? role String @default("MEMBER") projects ProjectMember[] assignments Task[] @relation("Assignee") comments TaskComment[] snippets Snippet[] } model Project { id String @id @default(cuid()) name String slug String @unique members ProjectMember[] tasks Task[] } model ProjectMember { user User @relation(fields: [userId], references: [id]) project Project @relation(fields: [projectId], references: [id]) userId String projectId String @@id([userId, projectId]) } model Task { id String @id @default(cuid()) title String description String? status String @default("TODO") order Float @default(0) project Project @relation(fields: [projectId], references: [id]) assignee User? @relation("Assignee", fields: [assigneeId], references: [id]) comments TaskComment[] projectId String assigneeId String? } model TaskComment { id String @id @default(cuid()) content String createdAt DateTime @default(now()) task Task @relation(fields: [taskId], references: [id]) author User @relation(fields: [authorId], references: [id]) taskId String authorId String } model Snippet { id String @id @default(cuid()) title String language String code String description String? author User @relation(fields: [authorId], references: [id]) tags SnippetTag[] authorId String createdAt DateTime @default(now()) } model SnippetTag { id String @id @default(cuid()) name String @unique snippets Snippet[] }设计说明两点。第一,Task.order用 Float 而不是 Int,因为看板拖拽排序时,我采用“两个卡片中间取中值”的策略,例如插入到 order 为 1 和 2 之间时取 1.5。Float 可以避免每次拖拽都重排整列所有数据。第二,ProjectMember用复合主键,天然保证一个用户在一个项目里只有一条记录,不用额外写查重逻辑。
3.2 迁移流程:migrate dev 和种子数据
建好模型之后,执行数据库迁移,把 schema 同步到 Postgres。
npx prisma migrate dev --name init这条命令会生成迁移 SQL、执行迁移,并重新生成 Prisma Client。执行完成后,检查一下生成的 SQL 文件,尤其是外键关系和 enum 类型,避免默认命名不符合业务预期。
做开发联调的时候,种子数据非常关键。没有任务数据的看板根本没法测拖拽和评论。我在 prisma/seed.ts 里写了两个示例项目,每个项目下放了 6 到 8 张任务卡,覆盖 TODO、DOING、DONE 三个状态。写入用 upsert,保证重复执行 seed 不会产生重复数据。
// prisma/seed.ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { const member = await prisma.user.upsert({ where: { email: "dev@example.com" }, update: {}, create: { email: "dev@example.com", name: "Developer", role: "ADMIN" }, }); const project = await prisma.project.upsert({ where: { slug: "t3code" }, update: {}, create: { slug: "t3code", name: "t3code 项目", members: { create: [{ userId: member.id }] }, }, }); for (let i = 1; i <= 6; i++) { await prisma.task.create({ data: { title: `示例任务 ${i}`, status: i % 3 === 0 ? "DONE" : i % 2 === 0 ? "DOING" : "TODO", projectId: project.id, assigneeId: member.id, }, }); } } main().catch(console.error).finally(() => prisma.$disconnect());别忘了在 package.json 里配置 seed 入口:
{ "prisma": { "seed": "tsx prisma/seed.ts" } }然后执行npx prisma db seed即可填充数据。注意 Prisma 6 的 seed 字段要求 seed 命令返回进程码,如果用了 tsx 确保文件里有退出逻辑。
3.3 Prisma 连接池:开发环境与生产环境的处理差异
踩过的第一个坑是开发环境对 PrismaClient 多次初始化。Next.js 开发模式的热更新会让模块反复重新执行,如果不做单例处理,每次热更新都会新建一个 PrismaClient,连接数很快被打满,控制台报错一大堆。T3 模板在 src/server/db.ts 里已经处理了这个单例,用 globalThis 存实例来避免重复创建。
// src/server/db.ts import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;生产环境部署到 Vercel 这类 serverless 平台时,问题又不一样。每个函数实例都可能创建自己的连接,数据库连接数很容易超。我的做法是给托管数据库开启连接池,让多条短连接被池化复用。如果你的 Postgres 供应商不提供连接池,那就必须把 Prisma 连接数调小,让 serverless 实例共享有限的连接。
4. 接口层:tRPC 如何做到端到端类型安全
4.1 为什么不用 REST,也不用 GraphQL
t3code 的接口层我直接选了 tRPC,这是 T3 Stack 的点睛之笔。REST 的问题在于类型不自治,你写了一个GET /api/task,前端还得另外定义一份 Response 类型,后端一改字段,前端可能毫无察觉。GraphQL 能解决类型问题,但引入 Schema、Resolver、Codegen 一堆概念,对一个小工具来说太重了。
tRPC 的思路完全不一样:接口就是 TypeScript 函数,类型就是函数签名本身。后端定义好 query 和 mutation,前端直接通过 import 拿到类型推导,不需要生成代码,不需要手写接口文档。这一点在生产环境意义最大:前端调用api.task.list.useQuery()拿到的数据,天然带有后端返回的所有类型提示,后端字段改名,前端编译直接失败,而不是上线后接口静默出错。
4.2 tRPC 初始化和 context:把会话信息传进每个接口
tRPC 需要一个根初始化文件,T3 模板的 src/server/api/trpc.ts 是核心。t3code 的 context 里放了两个东西:数据库实例和当前登录会话。
// src/server/api/trpc.ts import { initTRPC, TRPCError } from "@trpc/server"; import { getServerSession } from "next-auth"; import { authOptions } from "~/server/auth"; import { db } from "~/server/db"; export const createTRPCContext = async (opts: { headers: Headers }) => { const session = await getServerSession(authOptions); return { db, session, headers: opts.headers, }; }; const t = initTRPC.context<typeof createTRPCContext>().create(); const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }); }); export const router = t.router; export const publicProcedure = t.procedure; export const protectedProcedure = t.procedure.use(isAuthed);这里一个关键细节是 context 数据不要塞太多东西。我一开始把导航栏需要的未读计数也放了进去,结果每次 tRPC 调用都要查一次未读数,性能开销很大。正确的做法是只有每个接口都可能用到的全局数据才放 context,业务性强的数据放在具体 procedure 里查询。
4.3 任务路由的实现:Zod 校验 + 数据库操作
看板的核心操作都封装在 task router 里。列表查询、创建任务、更新状态、拖动排序、新增评论,每个 mutation 前先用 Zod 校验入参。
// src/server/api/routers/task.ts import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; export const taskRouter = createTRPCRouter({ list: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { return ctx.db.task.findMany({ where: { projectId: input.projectId }, include: { assignee: { select: { id: true, name: true, image: true } }, comments: { orderBy: { createdAt: "asc" } }, }, orderBy: { order: "asc" }, }); }), create: protectedProcedure .input( z.object({ projectId: z.string(), title: z.string().min(1).max(100), status: z.enum(["TODO", "DOING", "DONE"]).default("TODO"), }) ) .mutation(async ({ ctx, input }) => { const task = await ctx.db.task.create({ data: { projectId: input.projectId, title: input.title, status: input.status, order: await nextOrder(ctx.db, input.projectId), }, }); return task; }), updateStatus: protectedProcedure .input( z.object({ id: z.string(), status: z.enum(["TODO", "DOING", "DONE"]), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.task.update({ where: { id: input.id }, data: { status: input.status }, }); }), move: protectedProcedure .input( z.object({ id: z.string(), targetOrder: z.number(), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.task.update({ where: { id: input.id }, data: { order: input.targetOrder }, }); }), addComment: protectedProcedure .input( z.object({ taskId: z.string(), content: z.string().min(1).max(2000), }) ) .mutation(async ({ ctx, input }) => { return ctx.db.taskComment.create({ data: { taskId: input.taskId, content: input.content, authorId: ctx.session.user.id, }, }); }), }); async function nextOrder(db: PrismaClient, projectId: string) { const last = await db.task.findFirst({ where: { projectId }, orderBy: { order: "desc" }, }); return (last?.order ?? 0) + 1; }前端调用非常直观。比如看板某个卡片拖拽完成后,只需要调用api.task.move.useMutation(),并指定成功后 invalidate 列表缓存,看板会自动刷新,不需要手动管理全局状态。
const utils = api.useUtils(); const moveTask = api.task.move.useMutation({ onSuccess: () => utils.task.list.invalidate(), }); function handleDrop(taskId: string, targetOrder: number) { moveTask.mutate({ id: taskId, targetOrder }); }4.4 tRPC 的常见坑:类型推断和缓存失效
使用 tRPC 中常见的问题是异步 context 的用法。如果你的 context 构建函数里做了 async 操作,initTRPC 需要显式传入 context 类型,否则后续 process 里ctx类型推导不出来。模板里默认提供了正确的用法,不要自己去简化。
另一个坑是 react-query 版本。tRPC v11 对接的是 react-query v5,两者版本必须匹配,升级要一起升。缓存失效频率也要控制,像list这种只读查询,不需要每次 mutation 都 invalidate,可以在useMutation.onSuccess里精确指定要失效的 key,减少无意义请求。
5. 认证与权限:NextAuth 与 tRPC 中间件的组合
5.1 NextAuth 配置与 Prisma Adapter
认证这块我选了 NextAuth.js 加 Prisma Adapter。t3code 初期用邮箱加密码的 credentials 方式,后来发现密码哈希、找回密码、验证邮件全是坑,果断换成了 OAuth 社交账号登录,Github 和 Google 都能集成。团队内部工具用 OAuth 登录,省掉了整套密码管理流程。
// src/server/auth.ts import { type NextAuthOptions } from "next-auth"; import GitHubProvider from "next-auth/providers/github"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { db } from "~/server/db"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db), providers: [ GitHubProvider({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }), ], session: { strategy: "database" }, callbacks: { async session({ session, user }) { if (session.user && user) { session.user.id = user.id; } return session; }, }, };使用session: { strategy: "database" }后,会话存数据库,Prisma Adapter 会自动维护 Session 表。它的好处是可以在后台强制下线用户,管理员权限控制也更灵活。
5.2 在 tRPC 中间件里实现权限控制
登录保护通过 tRPC 中间件实现。第四节的protectedProcedure已经在执行具体逻辑前检查了登录态。但这还不够,管理员操作还需要细分权限。
比如成员管理里,添加成员这个操作只有管理员能做。我在 t3code 里给 User 表加了 role 字段,默认 MEMBER,管理员手动改成 ADMIN。
const isAdmin = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } if (ctx.session.user.role !== "ADMIN") { throw new TRPCError({ code: "FORBIDDEN" }); } return next({ ctx: { session: ctx.session } }); }); export const adminProcedure = t.procedure.use(isAdmin);这样在 member router 里:
addMember: adminProcedure .input(z.object({ email: z.string().email(), projectId: z.string() })) .mutation(async ({ ctx, input }) => { // 管理员才能添加成员 }),中间件的妙处在于把权限检查抽离出来,每个 router 不用重复写判断逻辑,后续如果需求变成“项目内再分角色”,只需要扩展中间件。
5.3 前端登录态与页面守卫
前端受保护页面用 Next.js 的 middleware 做拦截,避免用户看到登录页之前的空白。在 src/middleware.ts 里定义哪些路径不需要登录,其余访问都会重定向到 /signin。
// src/middleware.ts import { withAuth } from "next-auth/middleware"; export default withAuth({ pages: { signIn: "/signin" }, }); export const config = { matcher: ["/projects/:path*", "/snippets/:path*", "/settings/:path*"], };登录页本身用 tRPC 拿到 session 状态,已经登录就自动跳到看板。这套组合的好处是,处理器层面有 tRPC 中间件把关,路由层面有 middleware 把关,即使前端漏写了某个守卫,后端也不会放行,安全边界是双层的。
6. 部署上线:Vercel 与生产环境排坑实录
6.1 Vercel 部署的配置项
t3code 部署在 Vercel,整个部署几乎零配置。需要注意环境变量必须在 Vercel 项目设置里填全,包括 DATABASE_URL、NEXTAUTH_SECRET、NEXTAUTH_URL,以及 OAuth 回调地址。OAuth 提供方后台的回调 URL 必须写成https://你的域名/api/auth/callback/github,漏了这个,生产环境登录会一直回调失败。
部署时如果你的仓库有 pnpm-lock 或者 npm-lock,Vercel 会自动识别并安装依赖,无需额外配置。构建命令保持默认的next build。唯一要留意的是 Prisma 的生成命令,在 package.json 里加上 postinstall:
{ "scripts": { "postinstall": "prisma generate" } }这样每次构建前都会生成最新的 Prisma Client,不会因为 schema 变更后忘了重新生成而在线上跑旧模型。
6.2 生产环境三个典型问题的排查实录
第一个问题是 serverless 环境下 Prisma 连接数爆掉。看板页面一次拉取任务和评论,会涉及多张表查询,如果代码写得不够聚合,一个请求可能产生三四次数据库会话,并发一上来连接池就撑不住。我的解决办法是把多次小查询合并为一次大查询,用 include 把关联数据一次性带出,减少往返次数。
第二个问题是 tRPC 请求在部分浏览器下偶发跨域错误。排查发现是 NEXTAUTH_URL 没有设置成正式域名,导致 Cookie 写入的 Domain 不一致。tRPC 的请求跟着页面走,同域部署一般不涉及跨域,但要确保认证 Cookie 的 Secure 属性只在 https 下才生效。
第三个问题比较隐蔽:代码片段列表在服务端渲染时冷启动缓慢。原因是 snippet 列表页在 RSC 里直接调用了 Prisma 查询,首次请求要加载整个 Prisma 引擎。优化方案是把这个页面改成客户端渲染,通过 tRPC 的useQuery加载数据,同时让数据库查询结果在 react-query 缓存里复用,后续切换页面不会再触发冷启动。
6.3 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 本地启动报 Prisma 连接数过多 | 开发热更新重复创建 PrismaClient | 使用 globalThis 单例,核对 src/server/db.ts |
| 生产环境登录后回跳失败 | OAuth 回调地址没配全 | 检查提供方后台回调 URL 和 NEXTAUTH_URL |
| tRPC mutation 完成后列表不刷新 | react-query 缓存没失效 | mutation 的 onSuccess 里指定 invalidate 对应 query key |
| 看板拖拽后位置错乱 | order 字段用了整数且不重排 | 插入中间值时取相邻 order 的浮点中值 |
| 部署后 Prisma 模型不是最新 | 构建时没重新生成 Client | 添加 postinstall 脚本 prisma generate |
| 某些用户访问受保护页面直接 404 | middleware matcher 没覆盖路由路径 | 用 withAuth 统一拦截,配置 pages.signIn 重定向 |
这些坑大部分都能通过提前配置规避,真遇到了也别慌,先看日志再查环境变量,大部分问题都在配置层而不是代码层。
7. 写在 t3code 上线之后
项目上线后两周,团队反馈最集中的是“原来看板数据能跟着类型走真的很省心”。几个以前需要前后端反复确认的字段对齐问题,在 t3code 里根本没机会发生,TypeScript 和 tRPC 已经把类型边界焊死了。
如果你也想用 T3 Stack 做内部工具,我个人建议路线是:先照着这篇配置把最小看板跑通,再逐步加代码片段库和成员权限。不用迷信大而全的功能,团队工具的核心价值是让人愿意每天打开。最后再分享一个小细节:order 字段用浮点中值这个技巧,是我在第一次拖拽排序实现时踩了整数重排的坑之后总结的,在字段设计时就想好排序方式,后面至少能省一天调试时间。t3code 这个项目本身不算复杂,但把“类型安全、快速迭代、低维护成本”三个目标都稳稳落地了,这也是我会持续维护它的原因。