TypeScript 06:产品实战 —— Nuxt 全栈应用(Vue 生态的 "Django")
前面 04(后端)和 05(前端)是分开的两个项目。真实做产品时,小团队更常用全栈框架:
一个仓库、一种语言、一套类型,前后端共享模型定义,一条命令部署。
| 维度 | Django(你熟悉的) | Nuxt(Vue 全栈) | Next.js(React 全栈) |
|---|---|---|---|
| 页面渲染 | 模板 SSR | Vue SSR / SPA / 静态生成,可按路由配置 | React SSR |
| 路由 | urls.py |
文件即路由app/pages/*.vue |
文件即路由 |
| 接口 | views + DRF | server/api/*.ts |
app/api/*/route.ts |
| ORM | Django ORM | Prisma / Drizzle(自选) | 同左 |
| 认证 | django.contrib.auth |
nuxt-auth-utils 等模块 |
Auth.js |
| Admin | 自带 | 无(可用 Nuxt UI 快速搭) | 无 |
| 类型 | 运行时 | 前后端端到端类型推断 | 同左 |
本篇目标:做一个多用户在线记账产品——注册/登录、每人只能看到自己的账单、月度统计、可 Docker 部署。
1. 创建项目
npm create nuxt@latest accounting-app
cd accounting-app
npx nuxi module add auth-utils # 会话与密码哈希
npx nuxi module add @element-plus/nuxt # UI 组件库(自动按需导入)
npm install zod @prisma/client
npm install -D prisma
npx prisma init --datasource-provider sqlite
目录结构(Nuxt 4):
accounting-app/
├── app/
│ ├── pages/ # 页面路由(index.vue → /,login.vue → /login)
│ ├── components/ # 组件(自动导入,无需 import)
│ ├── composables/ # 组合式函数(自动导入)
│ ├── middleware/ # 路由守卫
│ └── app.vue
├── server/
│ ├── api/ # 接口:records.get.ts → GET /api/records
│ └── utils/ # 服务端工具(自动导入)
├── shared/ # 前后端共享代码(zod schema、类型)
├── prisma/schema.prisma
├── nuxt.config.ts
└── .env
"自动导入"是 Nuxt 的特色:
ref、computed、useFetch、app/components下的组件都不用手写 import,
但类型提示依旧完整(Nuxt 会生成.nuxt/types)。
2. 数据模型(Prisma ≈ Django models + migrations)
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL") // .env: DATABASE_URL="file:./dev.db"
}
model User {
id Int @id @default(autoincrement())
email String @unique
passwordHash String
createdAt DateTime @default(now())
records Record[]
}
model Record {
id Int @id @default(autoincrement())
type String // "income" | "expense"
category String
amount Float // 生产环境建议存"分"为 Int,避免浮点误差
date String // "2026-11-01"
note String?
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([userId, date])
}
npx prisma migrate dev --name init # ≈ makemigrations + migrate
npx prisma studio # 浏览器里看数据(≈ Django admin 的只读版)
// server/utils/prisma.ts —— 服务端自动导入,任何 server/ 文件可直接用 prisma
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
3. 共享校验 Schema(前后端用同一份)
// shared/schemas/record.ts
import { z } from "zod";
export const recordInputSchema = z.object({
type: z.enum(["income", "expense"]),
category: z.string().trim().min(1).max(20),
amount: z.number().positive().max(1_000_000),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
note: z.string().max(200).optional(),
});
export type RecordInput = z.infer<typeof recordInputSchema>;
export const credentialsSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
4. 认证(注册 / 登录 / 登出)
// shared/types/auth.d.ts —— 声明会话里 user 的类型
declare module "#auth-utils" {
interface User { id: number; email: string }
}
export {};
// server/api/auth/register.post.ts
import { credentialsSchema } from "~~/shared/schemas/record";
export default defineEventHandler(async (event) => {
const { email, password } = await readValidatedBody(event, credentialsSchema.parse);
const exists = await prisma.user.findUnique({ where: { email } });
if (exists) throw createError({ statusCode: 409, statusMessage: "邮箱已注册" });
const user = await prisma.user.create({
data: { email, passwordHash: await hashPassword(password) }, // hashPassword 来自 auth-utils
});
await setUserSession(event, { user: { id: user.id, email: user.email } });
return { id: user.id, email: user.email };
});
// server/api/auth/login.post.ts
import { credentialsSchema } from "~~/shared/schemas/record";
export default defineEventHandler(async (event) => {
const { email, password } = await readValidatedBody(event, credentialsSchema.parse);
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !(await verifyPassword(user.passwordHash, password))) {
throw createError({ statusCode: 401, statusMessage: "邮箱或密码错误" });
}
await setUserSession(event, { user: { id: user.id, email: user.email } });
return { ok: true };
});
会话存在加密 cookie 中,需在
.env配置NUXT_SESSION_PASSWORD(至少 32 位随机字符串)。
登出:前端调用useUserSession().clear()即可。
5. 业务接口(文件名即路由 + HTTP 方法)
// server/api/records/index.get.ts → GET /api/records?month=2026-11
import { z } from "zod";
const querySchema = z.object({ month: z.string().regex(/^\d{4}-\d{2}$/) });
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event); // 未登录自动返回 401
const { month } = await getValidatedQuery(event, querySchema.parse);
return prisma.record.findMany({
where: { userId: user.id, date: { startsWith: month } },
orderBy: { date: "desc" },
});
});
// server/api/records/index.post.ts → POST /api/records
import { recordInputSchema } from "~~/shared/schemas/record";
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event);
const data = await readValidatedBody(event, recordInputSchema.parse);
setResponseStatus(event, 201);
return prisma.record.create({ data: { ...data, userId: user.id } });
});
// server/api/records/[id].delete.ts → DELETE /api/records/:id
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event);
const id = Number(getRouterParam(event, "id"));
// 带上 userId 条件:防止删除别人的数据(越权,QA 必测点)
const { count } = await prisma.record.deleteMany({ where: { id, userId: user.id } });
if (count === 0) throw createError({ statusCode: 404, statusMessage: "记录不存在" });
setResponseStatus(event, 204);
return null;
});
// server/api/records/summary.get.ts → 月度分类统计
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event);
const month = String(getQuery(event).month ?? "");
const groups = await prisma.record.groupBy({
by: ["type", "category"],
where: { userId: user.id, date: { startsWith: month } },
_sum: { amount: true },
});
return groups.map(g => ({ type: g.type, category: g.category, total: g._sum.amount ?? 0 }));
});
6. 页面(端到端类型推断是最大亮点)
// app/middleware/auth.ts —— 路由守卫(≈ Django 的 @login_required)
export default defineNuxtRouteMiddleware(() => {
const { loggedIn } = useUserSession();
if (!loggedIn.value) return navigateTo("/login");
});
<!-- app/pages/login.vue -->
<script setup lang="ts">
const form = reactive({ email: "", password: "" });
const errorMsg = ref("");
const { fetch: refreshSession } = useUserSession();
async function login() {
try {
await $fetch("/api/auth/login", { method: "POST", body: form });
await refreshSession();
await navigateTo("/");
} catch (e: any) {
errorMsg.value = e.data?.statusMessage ?? "登录失败";
}
}
</script>
<template>
<el-form @submit.prevent="login" style="max-width: 360px; margin: 80px auto">
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<el-form-item label="密码"><el-input v-model="form.password" type="password" show-password /></el-form-item>
<el-alert v-if="errorMsg" :title="errorMsg" type="error" role="alert" />
<el-button type="primary" native-type="submit">登录</el-button>
</el-form>
</template>
<!-- app/pages/index.vue -->
<script setup lang="ts">
import { recordInputSchema, type RecordInput } from "~~/shared/schemas/record";
definePageMeta({ middleware: "auth" });
const month = ref(new Date().toISOString().slice(0, 7));
// records 的类型自动从 server/api/records/index.get.ts 的返回值推断出来,无需手写接口类型!
const { data: records, refresh } = await useFetch("/api/records", { query: { month } });
// query 里放 ref,month 变化时自动重新请求
const balance = computed(() =>
(records.value ?? []).reduce((s, r) => s + (r.type === "income" ? r.amount : -r.amount), 0));
const form = reactive<RecordInput>({ type: "expense", category: "", amount: 0, date: "" });
async function add() {
const parsed = recordInputSchema.safeParse(form); // 前端用同一份 schema 先校验
if (!parsed.success) return ElMessage.error(parsed.error.issues[0]!.message);
await $fetch("/api/records", { method: "POST", body: parsed.data });
await refresh();
}
async function remove(id: number) {
await $fetch(`/api/records/${id}`, { method: "DELETE" });
await refresh();
}
</script>
<template>
<el-date-picker v-model="month" type="month" value-format="YYYY-MM" />
<el-form inline @submit.prevent="add">
<el-select v-model="form.type" style="width: 100px">
<el-option label="支出" value="expense" /><el-option label="收入" value="income" />
</el-select>
<el-input v-model="form.category" placeholder="分类" style="width: 120px" />
<el-input-number v-model="form.amount" :min="0" :precision="2" />
<el-date-picker v-model="form.date" value-format="YYYY-MM-DD" />
<el-button type="primary" native-type="submit">添加</el-button>
</el-form>
<el-table :data="records ?? []" data-testid="record-table">
<el-table-column prop="date" label="日期" />
<el-table-column prop="category" label="分类" />
<el-table-column label="金额">
<template #default="{ row }">{{ row.type === "income" ? "+" : "-" }}{{ row.amount }}</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }"><el-button link @click="remove(row.id)">删除</el-button></template>
</el-table-column>
</el-table>
<p>结余:<strong data-testid="balance">{{ balance.toFixed(2) }}</strong></p>
</template>
7. 渲染模式:按路由选择(产品常见需求)
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["nuxt-auth-utils", "@element-plus/nuxt"],
routeRules: {
"/": { ssr: false }, // 记账主页:纯 SPA,登录后使用
"/about": { prerender: true }, // 介绍页:构建时生成静态 HTML,利于 SEO
"/blog/**": { swr: 3600 }, // 博客:SSR + 缓存 1 小时
},
});
8. 部署
npm run build # 产物在 .output/
node .output/server/index.mjs # 一个 Node 进程同时提供页面 + 接口
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npx prisma generate && npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/.output ./.output
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
ENV NODE_ENV=production PORT=3000
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
运行时需注入
DATABASE_URL、NUXT_SESSION_PASSWORD;首次部署执行npx prisma migrate deploy。
可以像本博客一样放在 nginx 后面反向代理(参考conf/下的 nginx 配置)。
9. 从 "能跑" 到 "产品" 的清单
| 能力 | 推荐方案 |
|---|---|
| UI 组件 | Element Plus / Nuxt UI |
| 国际化 | @nuxtjs/i18n |
| PWA(可安装到桌面/手机主屏) | @vite-pwa/nuxt |
| 文件上传 | readMultipartFormData(event) + 对象存储(S3/OSS) |
| 邮件 / 短信 | 调用 EAAS 之类的通信服务 API |
| 错误监控 | Sentry(@sentry/nuxt) |
| 后台任务 | Nitro 的scheduledTasks;重任务交给独立 worker |
| 测试 | Vitest(@nuxt/test-utils)+ Playwright |
后端很重时:NestJS(C# 开发者会非常熟悉)
如果业务复杂、团队大,可把后端独立出来用 NestJS,它的写法几乎就是 ASP.NET Core:
@Controller("records")
export class RecordsController {
constructor(private readonly service: RecordsService) {} // 依赖注入
@Get()
list(@Query("month") month: string) {
return this.service.list(month);
}
@Post()
@HttpCode(201)
create(@Body() dto: CreateRecordDto) {
return this.service.create(dto);
}
}
10. 练习
- 完成本篇项目,增加"编辑记录"(
server/api/records/[id].put.ts)。 - 用 Playwright 写越权测试:用户 A 创建记录,用户 B 登录后调用
DELETE /api/records/:id,期望 404。 - 增加
summary页面,用 ECharts 显示分类饼图。 - 加上
@vite-pwa/nuxt,在手机浏览器上"添加到主屏幕"体验离线打开。 - 用 Docker 部署到一台服务器,前面加 nginx + HTTPS。
文章评论 (0)