编程语言

TypeScript教程(六)产品实战 —— Nuxt 全栈应用(Vue 生态的 "Django")

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. 练习

  1. 完成本篇项目,增加"编辑记录"(server/api/records/[id].put.ts)。
  2. 用 Playwright 写越权测试:用户 A 创建记录,用户 B 登录后调用 DELETE /api/records/:id,期望 404。
  3. 增加 summary 页面,用 ECharts 显示分类饼图。
  4. 加上 @vite-pwa/nuxt,在手机浏览器上"添加到主屏幕"体验离线打开。
  5. 用 Docker 部署到一台服务器,前面加 nginx + HTTPS。

文章评论 (0)