创建新功能
从零开始添加"项目"功能的分步教程
教程:创建新功能
让我们从头开始构建一个完整的"项目"功能!您将学习:
- ✅ 创建数据库表
- ✅ 构建 tRPC API
- ✅ 创建 UI 组件
- ✅ 添加到仪表板
时间:20-30 分钟
难度:初学者
跟随操作:复制粘贴下面的代码。我们将解释每一步!
我们要构建什么
一个"项目"功能,用户可以:
- 创建新项目
- 列出所有项目
- 编辑项目详情
- 删除项目
创建数据库模式
创建文件:src/db/schema/projects.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { users } from './users';
export const projects = pgTable('projects', {
// ID - 每个项目的唯一标识符
id: uuid('id').defaultRandom().primaryKey(),
// 名称 - 项目标题
name: text('name').notNull(),
// 描述 - 可选详情
description: text('description'),
// 所有者 - 谁创建了这个项目
ownerId: text('owner_id')
.references(() => users.id)
.notNull(),
// 时间戳 - 创建/更新时间
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});这意味着什么:
uuid- 每个项目的随机 IDtext- 文本字段(名称、描述).notNull()- 此字段是必需的.references()- 链接到用户表timestamp- 日期/时间字段
导出模式
添加到 src/db/schema/index.ts:
export * from './users';
export * from './tasks';
export * from './posts';
export * from './projects'; // ← 添加这行运行迁移
创建并应用数据库迁移:
# 生成迁移文件
pnpm db:generate
# 应用到数据库
pnpm db:migrate您应该看到:✓ Migration applied successfully(迁移成功应用)
创建 tRPC 路由
创建文件:src/server/routers/projects.ts
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { projects } from '~/db/schema/projects';
import { eq } from 'drizzle-orm';
import { TRPCError } from '@trpc/server';
export const projectsRouter = createTRPCRouter({
// 列出所有用户的项目
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db
.select()
.from(projects)
.where(eq(projects.ownerId, ctx.session.user.id))
.orderBy(projects.createdAt);
}),
// 获取单个项目
get: protectedProcedure
.input(z.object({
id: z.string().uuid(),
}))
.query(async ({ ctx, input }) => {
const [project] = await ctx.db
.select()
.from(projects)
.where(eq(projects.id, input.id))
.limit(1);
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '项目未找到',
});
}
// 检查所有权
if (project.ownerId !== ctx.session.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '不是您的项目',
});
}
return project;
}),
// 创建新项目
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
const [project] = await ctx.db
.insert(projects)
.values({
name: input.name,
description: input.description,
ownerId: ctx.session.user.id,
})
.returning();
return project;
}),
// 更新项目
update: protectedProcedure
.input(z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
// 首先检查所有权
const [existing] = await ctx.db
.select()
.from(projects)
.where(eq(projects.id, id))
.limit(1);
if (!existing || existing.ownerId !== ctx.session.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
// 更新
const [updated] = await ctx.db
.update(projects)
.set({ ...data, updatedAt: new Date() })
.where(eq(projects.id, id))
.returning();
return updated;
}),
// 删除项目
delete: protectedProcedure
.input(z.object({
id: z.string().uuid(),
}))
.mutation(async ({ ctx, input }) => {
// 检查所有权
const [existing] = await ctx.db
.select()
.from(projects)
.where(eq(projects.id, input.id))
.limit(1);
if (!existing || existing.ownerId !== ctx.session.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
// 删除
await ctx.db
.delete(projects)
.where(eq(projects.id, input.id));
return { success: true };
}),
});注册路由
添加到 src/server/routers/index.ts:
import { projectsRouter } from './projects';
export const appRouter = createTRPCRouter({
tasks: tasksRouter,
posts: postsRouter,
projects: projectsRouter, // ← 添加这个
});创建 UI 组件
创建文件:src/components/dashboard/projects-list.tsx
'use client';
import { trpc } from '~/lib/trpc/client';
import { useState } from 'react';
export function ProjectsList() {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
// 获取项目
const { data: projects, isLoading } = trpc.projects.list.useQuery();
// 创建变更
const utils = trpc.useUtils();
const createProject = trpc.projects.create.useMutation({
onSuccess: () => {
// 刷新列表
utils.projects.list.invalidate();
// 清除表单
setName('');
setDescription('');
},
});
// 删除变更
const deleteProject = trpc.projects.delete.useMutation({
onSuccess: () => {
utils.projects.list.invalidate();
},
});
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
await createProject.mutateAsync({ name, description });
}
if (isLoading) {
return <div>加载项目中...</div>;
}
return (
<div className="space-y-6">
{/* 创建表单 */}
<form onSubmit={handleCreate} className="space-y-4">
<h2 className="text-xl font-bold">创建项目</h2>
<div>
<label className="block text-sm font-medium mb-1">
项目名称
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border rounded"
placeholder="我的超棒项目"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
描述
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 border rounded"
placeholder="这个项目是关于什么的?"
rows={3}
/>
</div>
<button
type="submit"
disabled={createProject.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{createProject.isPending ? '创建中...' : '创建项目'}
</button>
</form>
{/* 项目列表 */}
<div>
<h2 className="text-xl font-bold mb-4">您的项目</h2>
{projects?.length === 0 ? (
<p className="text-gray-500">还没有项目。在上面创建一个吧!</p>
) : (
<div className="grid gap-4">
{projects?.map((project) => (
<div
key={project.id}
className="p-4 border rounded hover:shadow-md"
>
<h3 className="font-semibold text-lg">{project.name}</h3>
{project.description && (
<p className="text-gray-600 mt-1">{project.description}</p>
)}
<div className="mt-3 flex gap-2">
<button
onClick={() => deleteProject.mutate({ id: project.id })}
className="text-red-600 hover:text-red-700"
>
删除
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
创建于:{new Date(project.createdAt).toLocaleDateString('zh-CN')}
</p>
</div>
))}
</div>
)}
</div>
</div>
);
}添加到仪表板
创建文件:src/app/(dashboard)/projects/page.tsx
import { ProjectsList } from '~/components/dashboard/projects-list';
export default function ProjectsPage() {
return (
<div className="container mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">项目</h1>
<ProjectsList />
</div>
);
}测试您的功能
- 启动开发服务器:
pnpm dev - 登录仪表板:
http://localhost:3000/dashboard - 访问:
http://localhost:3000/projects - 创建一个项目!
- 看到它出现在列表中
- 删除它
成功了!🎉
您学到了什么
✅ 数据库 - 使用 Drizzle ORM 创建表
✅ API - 使用 tRPC 构建类型安全的端点
✅ UI - 创建带状态的 React 组件
✅ 集成 - 将前端连接到后端
下一步
添加更多功能
现在您知道了模式,试试:
-
编辑功能
- 添加编辑按钮
- 显示编辑表单
- 调用
trpc.projects.update.useMutation()
-
项目详情页
- 创建
src/app/(dashboard)/projects/[id]/page.tsx - 使用
trpc.projects.get.useQuery({ id })
- 创建
-
过滤和搜索
- 添加搜索输入
- 按名称过滤项目
- 按日期/名称排序
-
分页
- 每页限制为 10 个
- 添加"加载更多"按钮
- 使用
useInfiniteQuery
常见问题
"表不存在"
解决方法:运行迁移:
pnpm db:migrate"无法读取 undefined 的属性"
解决方法:检查您是否已登录:
- protectedProcedure 需要身份验证
- 在
/sign-in登录
类型错误
解决方法:重启开发服务器:
# 使用 Ctrl+C 停止,然后:
pnpm dev想要更多教程?
恭喜! 您从头开始构建了一个完整的功能。继续构建!🚀