mkdir -p packages/ui
cd packages/ui
pnpm init
关键配置 (packages/ui/package.json
):
{
"name": "@demo/ui",
"version": "1.0.0",
"main": "dist/index.umd.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"dev": "vite build --watch",
"build": "vite build"
}
}
# 在根目录执行
pnpm add vue @vitejs/plugin-vue -D --filter @demo/ui
pnpm add sass -D -w # 公共开发依赖
// packages/ui/src/Button/Button.vue
<script setup lang="ts">
defineProps<{ text: string }>()
</script>
<template>
<button class="ui-button">{{ text }}</button>
</template>
<style scoped lang="scss">
.ui-button { background: #f0f; }
</style>
// packages/ui/src/index.ts
export { default as UButton } from './Button/Button.vue'
// packages/ui/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: 'src/index.ts',
name: 'DemoUI',
fileName: (format) => `index.${format}.js`
},
rollupOptions: {
external: ['vue'],
output: { globals: { vue: 'Vue' } }
}
}
})
mkdir -p apps/server
cd apps/server
pnpm init
# 在根目录执行(避免全局安装 CLI)
pnpm add @nestjs/core @nestjs/common rxjs reflect-metadata --filter @demo/server
pnpm add @nestjs/cli @nestjs/platform-express typescript @types/node -D --filter @demo/server
# 在 apps/server 目录操作
npx @nestjs/cli new . --package-manager=pnpm --skip-install
# 手动修复依赖引用(因 Monorepo 特殊结构)
修改 apps/server/package.json:
"dependencies": {
"@nestjs/common": "workspace:*",
"@nestjs/core": "workspace:*"
}
// apps/server/src/app.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getData() {
return { message: 'Hello from NestJS!' };
}
}
// apps/server/src/main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({ origin: 'http://localhost:5173' }); // Vue 应用默认端口
await app.listen(3000);
}
# 在根目录操作
pnpm add prisma @prisma/client --filter @demo/server
pnpm add -D prisma --filter @demo/server
# 进入 server 目录初始化
cd apps/server
pnpm exec prisma init
# 生成的文件结构:
apps/server/
├── prisma/
│ ├── schema.prisma # 数据模型定义
│ └── migrations/ # 迁移记录
└── .env # 数据库配置
# .env 文件
DATABASE_URL="mysql://root:password@localhost:3306/monorepo_db"
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
output = "../node_modules/.prisma/client" // 修复 Monorepo 路径问题
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
}
// apps/server/src/prisma/prisma.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common'
import { PrismaClient } from '@prisma/client'
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect()
}
}
// apps/server/src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common'
import { PrismaService } from './prisma.service'
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}
下一步将演示:
# 在 server 目录下执行
pnpm exec prisma migrate dev --name init
# 验证生成的 SQL
cat prisma/migrations/*/migration.sql
nest generate module users
nest generate controller users
nest generate service users
// apps/server/src/users/users.service.ts
import { Injectable } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async create(userData: { name: string; email: string }) {
return this.prisma.user.create({ data: userData })
}
async findAll() {
return this.prisma.user.findMany()
}
async findOne(id: number) {
return this.prisma.user.findUnique({ where: { id } })
}
}
// apps/server/src/users/users.controller.ts
import { Controller, Post, Body, Get, Param } from '@nestjs/common'
import { UsersService } from './users.service'
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() userData: { name: string; email: string }) {
return this.usersService.create(userData)
}
@Get()
findAll() {
return this.usersService.findAll()
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id)
}
}
# 启动服务端
pnpm --filter @demo/server start:dev
# 使用 curl 测试
curl -X POST -H "Content-Type: application/json" -d '{"name":"John","email":"[email protected]"}' http://localhost:3000/users
curl http://localhost:3000/users
User
表# 删除原 React 应用(如存在)
rm -rf apps/web-app
# 创建新 Vue 应用
pnpm create vite@latest web-app -- --template vue-ts
mv web-app apps/
# 安装必要依赖
pnpm add axios @demo/ui --filter @demo/web-app
pnpm add -D @types/node --filter @demo/web-app
// apps/web-app/src/api/user.ts
import axios from 'axios'
const api = axios.create({
baseURL: 'http://localhost:3000',
timeout: 5000
})
export const userAPI = {
createUser: (data: { name: string; email: string }) => api.post('/users', data),
getUsers: () => api.get('/users')
}
<script setup lang="ts">
import { ref } from 'vue'
import { UButton } from '@demo/ui'
import { userAPI } from '../api/user'
const formData = ref({ name: '', email: '' })
const handleSubmit = async () => {
try {
await userAPI.createUser(formData.value)
formData.value = { name: '', email: '' }
alert('用户创建成功!')
} catch (error) {
console.error('创建用户失败:', error)
}
}
script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="formData.name" placeholder="姓名">
<input v-model="formData.email" placeholder="邮箱">
<UButton type="submit" text="创建用户" />
form>
template>
// apps/web-app/src/styles/main.scss
@import '@demo/ui/styles'; // 引入组件库样式
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
// apps/web-app/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': '/src',
'@demo/ui': '../../packages/ui/src'
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/styles/main.scss";`
}
}
}
})
pnpm --filter @demo/server start:dev # 后端
pnpm --filter @demo/web-app dev # 前端
http://localhost:5173
Q1:跨域请求失败
enableCors
配置Q2:组件库样式未生效
additionalData
配置Q3:Prisma 客户端未初始化
.env
文件数据库连接字符串# 安装 Pinia
pnpm add pinia @vueuse/core --filter @demo/web-app
// apps/web-app/src/stores/user.ts
import { defineStore } from 'pinia'
import { userAPI } from '../api/user'
import { ref } from 'vue'
export const useUserStore = defineStore('users', () => {
const users = ref<any[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const fetchUsers = async () => {
try {
loading.value = true
const response = await userAPI.getUsers()
users.value = response.data
} catch (err) {
error.value = '获取用户列表失败'
} finally {
loading.value = false
}
}
return { users, loading, error, fetchUsers }
})
<script setup lang="ts">
import { useUserStore } from '../stores/user'
import { onMounted } from 'vue'
const store = useUserStore()
onMounted(() => {
store.fetchUsers()
})
script>
<template>
<div v-if="store.loading">加载中...div>
<div v-else-if="store.error" class="error">{{ store.error }}div>
<ul v-else>
<li v-for="user in store.users" :key="user.id">
{{ user.name }} - {{ user.email }}
li>
ul>
template>
<style scoped>
.error { color: red; }
style>
// 修改 UserForm.vue 中的 handleSubmit 方法
const handleSubmit = async () => {
try {
await userAPI.createUser(formData.value)
store.fetchUsers() // 创建成功后刷新列表
formData.value = { name: '', email: '' }
} catch (error) {
store.error = '用户创建失败'
}
}
# 在根目录执行
pnpm add @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt @types/passport-jwt --filter @demo/server
// apps/server/prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
name String
email String @unique
password String // 新增密码字段
}
执行迁移:
pnpm exec prisma migrate dev --name add_password_field
nest generate module auth
nest generate service auth
nest generate controller auth
// apps/server/src/auth/constants.ts
export const jwtConstants = {
secret: 'YOUR_SECRET_KEY', // 生产环境应使用环境变量
expiresIn: '60s' // 访问令牌有效期
};
// apps/server/src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(private prisma: PrismaService) {}
async validateUser(email: string, pass: string): Promise<any> {
const user = await this.prisma.user.findUnique({ where: { email } });
if (user && await bcrypt.compare(pass, user.password)) {
const { password, ...result } = user;
return result;
}
return null;
}
async register(userData: { name: string; email: string; password: string }) {
const hashedPassword = await bcrypt.hash(userData.password, 10);
return this.prisma.user.create({
data: { ...userData, password: hashedPassword }
});
}
}
// apps/server/src/auth/jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtConstants.secret,
});
}
async validate(payload: any) {
return { userId: payload.sub, email: payload.email };
}
}
// apps/server/src/auth/auth.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { JwtService } from '@nestjs/jwt';
@Controller('auth')
export class AuthController {
constructor(
private authService: AuthService,
private jwtService: JwtService
) {}
@Post('register')
async register(@Body() userData: { name: string; email: string; password: string }) {
return this.authService.register(userData);
}
@UseGuards(AuthGuard('local'))
@Post('login')
async login(@Body() user: { email: string; password: string }) {
const payload = { email: user.email, sub: user.id };
return {
access_token: this.jwtService.sign(payload),
};
}
}
<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';
import { useRouter } from 'vue-router';
const formData = ref({ email: '', password: '' });
const error = ref('');
const router = useRouter();
const handleLogin = async () => {
try {
const response = await axios.post('http://localhost:3000/auth/login', formData.value);
localStorage.setItem('access_token', response.data.access_token);
router.push('/dashboard');
} catch (err) {
error.value = '登录失败,请检查凭证';
}
};
script>
<template>
<div class="login-form">
<input v-model="formData.email" type="email" placeholder="邮箱">
<input v-model="formData.password" type="password" placeholder="密码">
<button @click="handleLogin">登录button>
<p v-if="error" class="error-message">{{ error }}p>
div>
template>
// apps/web-app/src/api/user.ts
api.interceptors.request.use(config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// apps/server/src/users/users.controller.ts
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
// 原有方法保持不变
}
// apps/web-app/src/api/user.ts
api.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
// 实现刷新令牌逻辑
const newToken = await refreshToken();
localStorage.setItem('access_token', newToken);
return api(originalRequest);
}
return Promise.reject(error);
}
);
curl -X POST -H "Content-Type: application/json" -d '{
"name": "Admin",
"email": "[email protected]",
"password": "P@ssw0rd"
}' http://localhost:3000/auth/register
curl -X POST -H "Content-Type: application/json" -d '{
"email": "[email protected]",
"password": "P@ssw0rd"
}' http://localhost:3000/auth/login
curl -H "Authorization: Bearer " http://localhost:3000/users
// apps/server/prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
name String
email String @unique
password String
roles UserRole[]
}
model Role {
id Int @id @default(autoincrement())
name String @unique
description String?
users UserRole[]
permissions RolePermission[]
}
model Permission {
id Int @id @default(autoincrement())
action String @unique // 示例:create_user
description String?
roles RolePermission[]
}
// 中间表
model UserRole {
userId Int @map("user_id")
roleId Int @map("role_id")
user User @relation(fields: [userId], references: [id])
role Role @relation(fields: [roleId], references: [id])
@@id([userId, roleId])
@@map("user_roles")
}
model RolePermission {
roleId Int @map("role_id")
permissionId Int @map("permission_id")
role Role @relation(fields: [roleId], references: [id])
permission Permission @relation(fields: [permissionId], references: [id])
@@id([roleId, permissionId])
@@map("role_permissions")
}
执行迁移:
pnpm exec prisma migrate dev --name add_rbac_models
// apps/server/src/auth/permissions.guard.ts
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private reflector: Reflector,
private jwtService: JwtService,
private prisma: PrismaService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredPermissions = this.reflector.get<string[]>(
'permissions',
context.getHandler()
);
if (!requiredPermissions) return true;
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(' ')[1];
const payload = this.jwtService.verify(token);
const userWithPermissions = await this.prisma.user.findUnique({
where: { id: payload.sub },
include: {
roles: {
include: {
role: {
include: {
permissions: {
include: { permission: true }
}
}
}
}
}
}
});
const userPermissions = userWithPermissions.roles
.flatMap(r => r.role.permissions)
.map(p => p.permission.action);
const hasPermission = requiredPermissions.every(p =>
userPermissions.includes(p)
);
if (!hasPermission) throw new ForbiddenException('权限不足');
return true;
}
}
// apps/server/src/auth/permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata('permissions', permissions);
nest generate module roles
nest generate controller roles
nest generate service roles
// apps/server/src/roles/roles.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class RolesService {
constructor(private prisma: PrismaService) {}
async createRole(name: string, description?: string) {
return this.prisma.role.create({
data: { name, description }
});
}
async assignRoleToUser(userId: number, roleId: number) {
return this.prisma.userRole.create({
data: { userId, roleId }
});
}
async addPermissionToRole(roleId: number, permissionId: number) {
return this.prisma.rolePermission.create({
data: { roleId, permissionId }
});
}
}
// apps/server/src/roles/roles.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { RolesService } from './roles.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermissions } from '../auth/permissions.decorator';
@Controller('roles')
@UseGuards(JwtAuthGuard)
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Post()
@RequirePermissions('manage_roles')
createRole(@Body() { name, description }: { name: string; description?: string }) {
return this.rolesService.createRole(name, description);
}
@Post('assign')
@RequirePermissions('manage_roles')
assignRole(@Body() { userId, roleId }: { userId: number; roleId: number }) {
return this.rolesService.assignRoleToUser(userId, roleId);
}
@Post('permissions')
@RequirePermissions('manage_permissions')
addPermission(@Body() { roleId, permissionId }: { roleId: number; permissionId: number }) {
return this.rolesService.addPermissionToRole(roleId, permissionId);
}
}
<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';
const newRole = ref({ name: '', description: '' });
const roleAssignment = ref({ userId: '', roleId: '' });
const createRole = async () => {
try {
await axios.post('/roles', newRole.value);
alert('角色创建成功');
} catch (error) {
console.error('创建角色失败:', error);
}
};
script>
<template>
<div class="role-management">
<h3>创建新角色h3>
<input v-model="newRole.name" placeholder="角色名称">
<input v-model="newRole.description" placeholder="描述">
<button @click="createRole">创建角色button>
div>
template>
// apps/web-app/src/router.ts
import { createRouter, createWebHistory } from 'vue-router';
import { useUserStore } from './stores/user';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('./components/UserList.vue') },
{
path: '/admin/roles',
component: () => import('./components/Admin/RoleManager.vue'),
meta: { requiresAuth: true, requiredPermissions: ['manage_roles'] }
}
]
});
router.beforeEach((to, from, next) => {
const store = useUserStore();
if (to.meta.requiresAuth && !store.isAuthenticated) {
next('/login');
} else if (to.meta.requiredPermissions) {
const hasPermission = to.meta.requiredPermissions.every(p =>
store.userPermissions.includes(p)
);
hasPermission ? next() : next('/forbidden');
} else {
next();
}
});
curl -X POST -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{
"name": "admin",
"description": "系统管理员"
}' http://localhost:3000/roles
curl -X POST -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{
"roleId": 1,
"permissionId": 1
}' http://localhost:3000/roles/permissions
# 使用普通用户令牌尝试访问管理接口
curl -X POST -H "Authorization: Bearer " http://localhost:3000/roles
# 应返回 403 Forbidden
$queryRaw
进行复杂权限查询user:create
、user:delete
)pnpm add winston winston-daily-rotate-file morgan nest-winston --filter @demo/server
// apps/server/src/logger/winston.config.ts
import { WinstonModuleOptions } from 'nest-winston';
import * as winston from 'winston';
import * as DailyRotateFile from 'winston-daily-rotate-file';
const transport = new DailyRotateFile({
filename: 'logs/application-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
});
export const winstonConfig: WinstonModuleOptions = {
levels: {
critical: 0,
error: 1,
warn: 2,
info: 3,
debug: 4,
},
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}),
transport
],
};
// apps/server/src/main.ts
import { WinstonModule } from 'nest-winston';
import { winstonConfig } from './logger/winston.config';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: WinstonModule.createLogger(winstonConfig)
});
// ...其他配置
}
pnpm add @sentry/vue @sentry/tracing --filter @demo/web-app
// apps/web-app/src/main.ts
import * as Sentry from '@sentry/vue';
const app = createApp(App);
Sentry.init({
app,
dsn: 'YOUR_DSN_HERE',
integrations: [
new Sentry.BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
}),
],
tracesSampleRate: 1.0,
});
pnpm add @nestjs/metrics prom-client --filter @demo/server
// apps/server/src/metrics/metrics.module.ts
import { Module } from '@nestjs/common';
import { PrometheusModule } from '@nestjs/metrics';
@Module({
imports: [
PrometheusModule.register({
defaultLabels: ['app'],
path: '/metrics',
})
],
exports: [PrometheusModule]
})
export class MetricsModule {}
# apps/server/Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm i -g pnpm && pnpm i --frozen-lockfile
COPY . .
RUN pnpm build
ENV NODE_ENV production
EXPOSE 3000
CMD ["node", "dist/main.js"]
version: '3.8'
services:
server:
build: ./apps/server
ports:
- "3000:3000"
environment:
- DATABASE_URL=mysql://root:password@db:3306/monorepo_db
depends_on:
- db
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: monorepo_db
volumes:
- mysql_data:/var/lib/mysql
volumes:
mysql_data:
相关文章:「手把手教学」拆解 Monorepo 搭建与管理全流程