Nuxt.js SSR 项目完整实践指南(SEO/GEO 优化 + 常用功能实现)
一、项目初始化与环境准备
1.1 环境要求
# Node.js >= 18.0.0(推荐 20 LTS)
node -v
# 包管理器(推荐 pnpm)
npm install -g pnpm
1.2 创建项目
npx nuxi@latest init my-nuxt-app
cd my-nuxt-app
# 选择时推荐:
# – Package manager: pnpm
# – UI framework: Element Plus / Naive UI / 无(按需)
# – Features: TypeScript, ESLint, Prettier
# – CSS: Tailwind CSS / UnoCSS
pnpm install
1.3 安装核心依赖
# 状态管理
pnpm add pinia @pinia/nuxt
# HTTP 请求
pnpm add axios
# UI 组件库(以 Element Plus 为例)
pnpm add element-plus @element-plus/icons-vue
# SEO 模块
pnpm add @nuxtjs/sitemap @nuxtjs/robots nuxt-schema-org
# 认证相关
pnpm add @sidebase/nuxt-auth jwt-decode bcryptjs
# 工具库
pnpm add dayjs lodash-es
# 开发依赖
pnpm add -D @types/lodash-es sass unplugin-auto-import unplugin-vue-components
二、项目目录结构
my-nuxt-app/
├── app.vue # 根组件
├── nuxt.config.ts # 核心配置
├── tsconfig.json # TypeScript 配置
├── tailwind.config.ts # Tailwind 配置
├── .env # 环境变量
├── .env.production # 生产环境变量
│
├── assets/ # 静态资源(会被编译)
│ ├── css/
│ │ └── main.scss
│ └── images/
│
├── public/ # 公共静态资源(不编译)
│ ├── favicon.ico
│ ├── robots.txt
│ └── logo.png
│
├── components/ # 可复用组件(自动导入)
│ ├── common/
│ │ ├── AppHeader.vue
│ │ ├── AppFooter.vue
│ │ └── Breadcrumb.vue
│ ├── auth/
│ │ ├── LoginForm.vue
│ │ └── RegisterForm.vue
│ └── department/
│ ├── DeptTree.vue
│ ├── DeptForm.vue
│ └── DeptTable.vue
│
├── composables/ # 组合式函数(自动导入)
│ ├── useAuth.ts
│ ├── useDepartment.ts
│ └── usePagination.ts
│
├── layouts/ # 布局
│ ├── default.vue
│ ├── auth.vue
│ └── admin.vue
│
├── middleware/ # 路由中间件
│ ├── auth.global.ts # 全局认证守卫
│ └── admin.ts
│
├── pages/ # 页面路由(文件系统路由)
│ ├── index.vue # 首页 /
│ ├── about.vue # /about
│ ├── login.vue # /login
│ ├── register.vue # /register
│ └── admin/
│ ├── index.vue # /admin
│ ├── departments.vue # /admin/departments
│ └── departments/
│ └── [id].vue # /admin/departments/:id
│
├── plugins/ # 插件
│ ├── element-plus.ts
│ └── axios.ts
│
├── server/ # Nitro 服务端(API Routes)
│ ├── api/
│ │ ├── auth/
│ │ │ ├── login.post.ts
│ │ │ ├── register.post.ts
│ │ │ ├── logout.post.ts
│ │ │ └── me.get.ts
│ │ └── departments/
│ │ ├── index.get.ts
│ │ ├── index.post.ts
│ │ ├── [id].get.ts
│ │ ├── [id].put.ts
│ │ └── [id].delete.ts
│ ├── middleware/
│ │ └── auth.ts
│ └── utils/
│ ├── db.ts
│ └── jwt.ts
│
├── stores/ # Pinia 状态管理
│ ├── auth.ts
│ └── department.ts
│
├── types/ # 类型定义
│ ├── api.d.ts
│ ├── department.d.ts
│ └── user.d.ts
│
└── utils/ # 工具函数(自动导入)
├── request.ts
├── validators.ts
└── format.ts
三、核心配置文件详解
3.1 nuxt.config.ts(完整配置)
// nuxt.config.ts
export default defineNuxtConfig({
// ==================== 基础配置 ====================
ssr: true, // ✅ 开启服务端渲染(SEO核心)
devtools: { enabled: true },
typescript: {
strict: true,
typeCheck: true,
},
// ==================== 模块 ====================
modules: [
'@pinia/nuxt',
'@nuxtjs/tailwindcss',
'@nuxtjs/sitemap',
'@nuxtjs/robots',
'nuxt-schema-org',
],
// ==================== App 元信息(SEO 基础) ====================
app: {
head: {
htmlAttrs: { lang: 'zh-CN' },
title: '企业管理系统 – 高效协作平台',
titleTemplate: '%s | 企业管理系统',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{
name: 'description',
content: '一站式企业管理平台,提供部门管理、人员协作、项目跟踪等功能,助力企业数字化转型。'
},
{ name: 'keywords', content: '企业管理,部门管理,协作平台,数字化办公' },
{ name: 'author', content: 'YourCompany' },
// Open Graph(社交分享)
{ property: 'og:type', content: 'website' },
{ property: 'og:site_name', content: '企业管理系统' },
{ property: 'og:locale', content: 'zh_CN' },
// Twitter Card
{ name: 'twitter:card', content: 'summary_large_image' },
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'canonical', href: 'https://www.yourdomain.com' },
],
},
pageTransition: { name: 'page', mode: 'out-in' },
layoutTransition: { name: 'layout', mode: 'out-in' },
},
// ==================== 运行时配置 ====================
runtimeConfig: {
// 仅服务端可访问(不会暴露给客户端)
jwtSecret: process.env.JWT_SECRET || 'your-super-secret-key',
dbHost: process.env.DB_HOST,
dbPort: process.env.DB_PORT,
dbUser: process.env.DB_USER,
dbPassword: process.env.DB_PASSWORD,
dbName: process.env.DB_NAME,
// 客户端也可访问(以 public 开头)
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:3000',
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://www.yourdomain.com',
appName: '企业管理系统',
},
},
// ==================== CSS ====================
css: [
'element-plus/dist/index.css',
'~/assets/css/main.scss',
],
// ==================== Vite 构建配置 ====================
vite: {
css: {
preprocessorOptions: {
scss: {
additionalData: '@use "~/assets/css/variables.scss" as *;',
},
},
},
},
// ==================== Nitro 服务端配置 ====================
nitro: {
compressPublicAssets: true,
prerender: {
// 预渲染静态页面(SSG 混合模式)
routes: ['/', '/about', '/contact'],
},
},
// ==================== Sitemap 配置(SEO) ====================
sitemap: {
siteUrl: 'https://www.yourdomain.com',
autoLastmod: true,
defaults: {
changefreq: 'daily',
priority: 0.8,
},
},
// ==================== Robots 配置(SEO) ====================
robots: {
groups: [
{
userAgent: ['*'],
allow: '/',
disallow: ['/admin', '/login', '/register', '/api/'],
},
],
sitemap: 'https://www.yourdomain.com/sitemap.xml',
},
// ==================== Schema.org 结构化数据(SEO + GEO) ====================
schemaOrg: {
identity: {
type: 'Organization',
name: 'YourCompany',
url: 'https://www.yourdomain.com',
logo: 'https://www.yourdomain.com/logo.png',
description: '一站式企业管理平台',
sameAs: [
'https://weibo.com/yourcompany',
'https://github.com/yourcompany',
],
},
},
// ==================== 兼容性日期 ====================
compatibilityDate: '2025-07-01',
})
3.2 .env 环境变量
# .env
JWT_SECRET=my-super-secret-jwt-key-2026
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=enterprise_db
NUXT_PUBLIC_API_BASE=http://localhost:3000
NUXT_PUBLIC_SITE_URL=https://www.yourdomain.com
3.3 tsconfig.json
{
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"~/*": ["./*"],
"@/*": ["./*"]
}
}
}
四、SEO/GEO 优化实践
4.1 页面级 SEO Meta(动态设置)
<!– pages/index.vue –>
<script setup lang="ts">
// 首页 SEO 配置
useSeoMeta({
title: '企业管理系统 – 高效数字化协作平台',
description: '提供部门管理、人员协作、项目跟踪的一站式企业管理解决方案,助力企业数字化转型。',
ogTitle: '企业管理系统',
ogDescription: '一站式企业管理平台,助力数字化转型',
ogImage: 'https://www.yourdomain.com/og-image.png',
twitterCard: 'summary_large_image',
})
// 结构化数据(GEO 核心:让 AI 引擎理解页面内容)
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebApplication',
name: '企业管理系统',
description: '一站式企业管理平台,提供部门管理、人员协作等功能',
url: 'https://www.yourdomain.com',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'CNY',
},
featureList: [
'部门管理',
'人员管理',
'权限控制',
'数据报表',
],
}),
},
],
})
</script>
<template>
<div>
<!– 语义化 HTML 结构(SEO + GEO 友好) –>
<header>
<h1>企业管理系统</h1>
<p>高效数字化协作平台,助力企业转型升级</p>
</header>
<main>
<section aria-label="核心功能">
<h2>核心功能</h2>
<article>
<h3>部门管理</h3>
<p>支持多级部门树形结构,灵活配置组织架构…</p>
</article>
</section>
</main>
</div>
</template>
4.2 动态路由页面 SEO
<!– pages/admin/departments/[id].vue –>
<script setup lang="ts">
const route = useRoute()
const { data: dept } = await useAsyncData(
`dept-${route.params.id}`,
() => $fetch(`/api/departments/${route.params.id}`)
)
// 动态 SEO 元信息
useSeoMeta({
title: `${dept.value?.name} – 部门详情`,
description: `${dept.value?.name}部门信息,${dept.value?.description || ''}`,
})
// 面包屑结构化数据(GEO 友好)
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: '首页', item: 'https://www.yourdomain.com' },
{ '@type': 'ListItem', position: 2, name: '部门管理', item: 'https://www.yourdomain.com/admin/departments' },
{ '@type': 'ListItem', position: 3, name: dept.value?.name },
],
}),
},
],
})
</script>
4.3 GEO 优化要点(面向 AI 生成式引擎)
<!– components/common/StructuredFAQ.vue –>
<script setup lang="ts">
// FAQ 结构化数据 – 让 AI 引擎直接引用
const faqData = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: '企业管理系统支持哪些功能?',
acceptedAnswer: {
'@type': 'Answer',
text: '本系统支持部门管理、人员管理、权限控制、数据报表、项目跟踪等核心功能,适用于中大型企业的数字化管理需求。',
},
},
{
'@type': 'Question',
name: '如何创建部门?',
acceptedAnswer: {
'@type': 'Answer',
text: '登录管理后台后,进入"部门管理"页面,点击"新增部门"按钮,填写部门名称、上级部门、负责人等信息即可创建。支持多级树形结构。',
},
},
],
}
useHead({
script: [{ type: 'application/ld+json', innerHTML: JSON.stringify(faqData) }],
})
</script>
4.4 public/robots.txt
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /login
Disallow: /register
Disallow: /api/
# AI 爬虫(GEO)
User-agent: GPTBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Bytespider
Allow: /
Sitemap: https://www.yourdomain.com/sitemap.xml
五、布局与插件配置
5.1 布局文件
<!– layouts/default.vue –>
<template>
<div class="min-h-screen flex flex-col">
<AppHeader />
<main class="flex-1 container mx-auto px-4 py-8">
<slot />
</main>
<AppFooter />
</div>
</template>
<!– layouts/admin.vue –>
<template>
<div class="flex min-h-screen">
<!– 侧边栏 –>
<aside class="w-64 bg-gray-900 text-white">
<div class="p-4 text-xl font-bold">管理后台</div>
<nav class="mt-4">
<NuxtLink to="/admin" class="block px-4 py-2 hover:bg-gray-800">
仪表盘
</NuxtLink>
<NuxtLink to="/admin/departments" class="block px-4 py-2 hover:bg-gray-800">
部门管理
</NuxtLink>
</nav>
</aside>
<!– 主内容 –>
<div class="flex-1 flex flex-col">
<header class="h-16 bg-white shadow flex items-center px-6 justify-between">
<Breadcrumb />
<div class="flex items-center gap-4">
<span>{{ authStore.user?.name }}</span>
<el-button @click="handleLogout" type="danger" size="small">退出</el-button>
</div>
</header>
<main class="flex-1 p-6 bg-gray-50">
<slot />
</main>
</div>
</div>
</template>
<script setup lang="ts">
const authStore = useAuthStore()
const router = useRouter()
const handleLogout = async () => {
await authStore.logout()
router.push('/login')
}
</script>
5.2 Element Plus 插件
// plugins/element-plus.ts
import { defineNuxtPlugin } from '#app'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(ElementPlus, {
locale: zhCn,
size: 'default',
})
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
nuxtApp.vueApp.component(key, component)
}
})
5.3 Axios 插件
// plugins/axios.ts
import { defineNuxtPlugin } from '#app'
import axios from 'axios'
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const instance = axios.create({
baseURL: config.public.apiBase,
timeout: 15000,
headers: { 'Content-Type': 'application/json' },
})
// 请求拦截器:注入 Token
instance.interceptors.request.use((config) => {
if (import.meta.client) {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
}
return config
})
// 响应拦截器:统一错误处理
instance.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
// Token 过期,跳转登录
if (import.meta.client) {
localStorage.removeItem('access_token')
navigateTo('/login')
}
}
return Promise.reject(error)
}
)
return {
provide: { axios: instance },
}
})
六、登录注册功能完整实现
6.1 类型定义
// types/user.d.ts
interface User {
id: number
username: string
name: string
email: string
phone?: string
avatar?: string
role: 'admin' | 'manager' | 'user'
departmentId?: number
status: 'active' | 'disabled'
createdAt: string
updatedAt: string
}
interface LoginParams {
username: string
password: string
remember?: boolean
}
interface RegisterParams {
username: string
password: string
confirmPassword: string
name: string
email: string
phone?: string
}
interface AuthResponse {
token: string
refreshToken: string
user: User
}
interface ApiResponse<T = any> {
code: number
message: string
data: T
}
6.2 服务端 API – JWT 工具
// server/utils/jwt.ts
import jwt from 'jsonwebtoken'
const config = useRuntimeConfig()
export function generateToken(payload: { userId: number; role: string }) {
return jwt.sign(payload, config.jwtSecret, { expiresIn: '2h' })
}
export function generateRefreshToken(payload: { userId: number }) {
return jwt.sign(payload, config.jwtSecret, { expiresIn: '7d' })
}
export function verifyToken(token: string) {
try {
return jwt.verify(token, config.jwtSecret) as { userId: number; role: string }
} catch {
return null
}
}
6.3 服务端 API – 登录接口
// server/api/auth/login.post.ts
import { defineEventHandler, readBody, createError } from 'h3'
import bcrypt from 'bcryptjs'
// 假设使用数据库(此处用模拟数据演示)
export default defineEventHandler(async (event) => {
const body = await readBody<LoginParams>(event)
// 参数校验
if (!body.username || !body.password) {
throw createError({ statusCode: 400, message: '用户名和密码不能为空' })
}
// 查询用户(实际项目使用数据库)
// const user = await db.user.findUnique({ where: { username: body.username } })
const mockUser = {
id: 1,
username: 'admin',
name: '管理员',
email: 'admin@example.com',
role: 'admin',
password: await bcrypt.hash('admin123', 10), // 模拟
status: 'active',
}
if (!mockUser) {
throw createError({ statusCode: 401, message: '用户名或密码错误' })
}
// 验证密码
const isValid = await bcrypt.compare(body.password, mockUser.password)
if (!isValid) {
throw createError({ statusCode: 401, message: '用户名或密码错误' })
}
// 生成 Token
const token = generateToken({ userId: mockUser.id, role: mockUser.role })
const refreshToken = generateRefreshToken({ userId: mockUser.id })
// 设置 HttpOnly Cookie(更安全)
setCookie(event, 'access_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7200, // 2小时
path: '/',
})
setCookie(event, 'refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 604800, // 7天
path: '/',
})
return {
code: 200,
message: '登录成功',
data: {
token,
refreshToken,
user: {
id: mockUser.id,
username: mockUser.username,
name: mockUser.name,
email: mockUser.email,
role: mockUser.role,
},
},
}
})
6.4 服务端 API – 注册接口
// server/api/auth/register.post.ts
import { defineEventHandler, readBody, createError } from 'h3'
import bcrypt from 'bcryptjs'
export default defineEventHandler(async (event) => {
const body = await readBody<RegisterParams>(event)
// 参数校验
const errors: string[] = []
if (!body.username || body.username.length < 3) errors.push('用户名至少3个字符')
if (!body.password || body.password.length < 6) errors.push('密码至少6个字符')
if (body.password !== body.confirmPassword) errors.push('两次密码不一致')
if (!body.email || !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(body.email)) errors.push('邮箱格式不正确')
if (!body.name) errors.push('姓名不能为空')
if (errors.length > 0) {
throw createError({ statusCode: 400, message: errors.join(';') })
}
// 检查用户名是否已存在
// const existing = await db.user.findUnique({ where: { username: body.username } })
const existing = null // 模拟
if (existing) {
throw createError({ statusCode: 409, message: '用户名已存在' })
}
// 创建用户
const hashedPassword = await bcrypt.hash(body.password, 12)
const newUser = {
id: Date.now(),
username: body.username,
name: body.name,
email: body.email,
phone: body.phone,
password: hashedPassword,
role: 'user',
status: 'active',
createdAt: new Date().toISOString(),
}
// await db.user.create({ data: newUser })
return {
code: 200,
message: '注册成功',
data: {
id: newUser.id,
username: newUser.username,
name: newUser.name,
},
}
})
6.5 Pinia Store – 认证状态
// stores/auth.ts
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
token: null as string | null,
isAuthenticated: false,
}),
getters: {
isAdmin: (state) => state.user?.role === 'admin',
userName: (state) => state.user?.name || '未登录',
},
actions: {
async login(params: LoginParams) {
try {
const res = await $fetch<ApiResponse<AuthResponse>>('/api/auth/login', {
method: 'POST',
body: params,
})
this.token = res.data.token
this.user = res.data.user
this.isAuthenticated = true
// 客户端持久化
if (import.meta.client) {
localStorage.setItem('access_token', res.data.token)
localStorage.setItem('user', JSON.stringify(res.data.user))
}
return res
} catch (error: any) {
throw new Error(error.data?.message || '登录失败')
}
},
async register(params: RegisterParams) {
try {
const res = await $fetch<ApiResponse>('/api/auth/register', {
method: 'POST',
body: params,
})
return res
} catch (error: any) {
throw new Error(error.data?.message || '注册失败')
}
},
async fetchUser() {
try {
const res = await $fetch<ApiResponse<User>>('/api/auth/me')
this.user = res.data
this.isAuthenticated = true
} catch {
this.logout()
}
},
async logout() {
try {
await $fetch('/api/auth/logout', { method: 'POST' })
} finally {
this.user = null
this.token = null
this.isAuthenticated = false
if (import.meta.client) {
localStorage.removeItem('access_token')
localStorage.removeItem('user')
}
}
},
// 从 localStorage 恢复状态
restoreFromStorage() {
if (import.meta.client) {
const token = localStorage.getItem('access_token')
const userStr = localStorage.getItem('user')
if (token && userStr) {
this.token = token
this.user = JSON.parse(userStr)
this.isAuthenticated = true
}
}
},
},
})
6.6 路由中间件 – 认证守卫
// middleware/auth.global.ts
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore()
// 客户端恢复状态
if (import.meta.client && !authStore.isAuthenticated) {
authStore.restoreFromStorage()
}
// 需要认证的页面
const protectedRoutes = ['/admin']
const isProtected = protectedRoutes.some((route) => to.path.startsWith(route))
if (isProtected && !authStore.isAuthenticated) {
return navigateTo(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
}
// 已登录用户访问登录页,重定向到首页
if ((to.path === '/login' || to.path === '/register') && authStore.isAuthenticated) {
return navigateTo('/admin')
}
})
6.7 登录页面
<!– pages/login.vue –>
<script setup lang="ts">
definePageMeta({ layout: 'auth' })
useSeoMeta({
title: '登录',
description: '登录企业管理系统,开始高效协作',
})
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const formRef = ref()
const form = reactive<LoginParams>({
username: '',
password: '',
remember: false,
})
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, message: '用户名至少3个字符', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6个字符', trigger: 'blur' },
],
}
const handleLogin = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await authStore.login(form)
ElMessage.success('登录成功')
const redirect = (route.query.redirect as string) || '/admin'
router.push(redirect)
} catch (error: any) {
ElMessage.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div class="w-full max-w-md bg-white rounded-2xl shadow-xl p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-800">企业管理系统</h1>
<p class="text-gray-500 mt-2">欢迎回来,请登录您的账号</p>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="handleLogin">
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
size="large"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<div class="flex items-center justify-between mb-6">
<el-checkbox v-model="form.remember">记住我</el-checkbox>
<NuxtLink to="/forgot-password" class="text-blue-500 text-sm hover:underline">
忘记密码?
</NuxtLink>
</div>
<el-button
type="primary"
size="large"
:loading="loading"
class="w-full"
@click="handleLogin"
>
登 录
</el-button>
</el-form>
<div class="text-center mt-6 text-sm text-gray-500">
还没有账号?
<NuxtLink to="/register" class="text-blue-500 hover:underline">立即注册</NuxtLink>
</div>
</div>
</div>
</template>
6.8 注册页面
<!– pages/register.vue –>
<script setup lang="ts">
definePageMeta({ layout: 'auth' })
useSeoMeta({
title: '注册',
description: '注册企业管理系统账号,开启数字化管理之旅',
})
const authStore = useAuthStore()
const router = useRouter()
const loading = ref(false)
const formRef = ref()
const form = reactive<RegisterParams>({
username: '',
password: '',
confirmPassword: '',
name: '',
email: '',
phone: '',
})
const validateConfirmPassword = (rule: any, value: string, callback: any) => {
if (value !== form.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度3-20个字符', trigger: 'blur' },
],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6个字符', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' },
],
}
const handleRegister = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await authStore.register(form)
ElMessage.success('注册成功,请登录')
router.push('/login')
} catch (error: any) {
ElMessage.error(error.message || '注册失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-teal-100">
<div class="w-full max-w-md bg-white rounded-2xl shadow-xl p-8">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-gray-800">创建账号</h1>
<p class="text-gray-500 mt-2">填写以下信息完成注册</p>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="3-20个字符" prefix-icon="User" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入真实姓名" prefix-icon="Postcard" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" prefix-icon="Message" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" placeholder="选填" prefix-icon="Phone" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" placeholder="至少6个字符" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" placeholder="再次输入密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-button type="primary" size="large" :loading="loading" class="w-full mt-4" @click="handleRegister">
注 册
</el-button>
</el-form>
<div class="text-center mt-6 text-sm text-gray-500">
已有账号?
<NuxtLink to="/login" class="text-blue-500 hover:underline">去登录</NuxtLink>
</div>
</div>
</div>
</template>
七、部门管理功能完整实现
7.1 类型定义
// types/department.d.ts
interface Department {
id: number
name: string
code: string
parentId: number | null
parentName?: string
leader?: string
leaderId?: number
phone?: string
email?: string
sort: number
status: 'active' | 'disabled'
description?: string
children?: Department[]
memberCount?: number
createdAt: string
updatedAt: string
}
interface DeptCreateParams {
name: string
code: string
parentId: number | null
leader?: string
phone?: string
email?: string
sort?: number
status?: 'active' | 'disabled'
description?: string
}
interface DeptQueryParams {
keyword?: string
status?: string
page?: number
pageSize?: number
}
7.2 服务端 API – 部门 CRUD
// server/api/departments/index.get.ts
import { defineEventHandler, getQuery } from 'h3'
export default defineEventHandler(async (event) => {
const query = getQuery<DeptQueryParams>(event)
const { keyword, status, page = 1, pageSize = 20 } = query
// 实际项目使用数据库查询
// const departments = await db.department.findMany({ … })
// 模拟数据
const allDepts: Department[] = [
{
id: 1, name: '总公司', code: 'ROOT', parentId: null,
leader: '张三', sort: 0, status: 'active',
memberCount: 150, createdAt: '2024-01-01', updatedAt: '2024-01-01',
children: [
{
id: 2, name: '技术部', code: 'TECH', parentId: 1,
leader: '李四', sort: 1, status: 'active',
memberCount: 45, createdAt: '2024-01-01', updatedAt: '2024-01-01',
children: [
{ id: 4, name: '前端组', code: 'FE', parentId: 2, leader: '王五', sort: 1, status: 'active', memberCount: 15, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
{ id: 5, name: '后端组', code: 'BE', parentId: 2, leader: '赵六', sort: 2, status: 'active', memberCount: 20, createdAt: '2024-01-01', updatedAt: '2024-01-01' },
],
},
{
id: 3, name: '市场部', code: 'MKT', parentId: 1,
leader: '孙七', sort: 2, status: 'active',
memberCount: 30, createdAt: '2024-01-01', updatedAt: '2024-01-01',
},
],
},
]
// 过滤
let filtered = allDepts
if (keyword) {
filtered = allDepts.filter((d) => d.name.includes(keyword) || d.code.includes(keyword))
}
if (status) {
filtered = allDepts.filter((d) => d.status === status)
}
return {
code: 200,
message: 'success',
data: {
list: filtered,
total: filtered.length,
page: Number(page),
pageSize: Number(pageSize),
},
}
})
// server/api/departments/index.post.ts
import { defineEventHandler, readBody, createError } from 'h3'
export default defineEventHandler(async (event) => {
const body = await readBody<DeptCreateParams>(event)
// 校验
if (!body.name?.trim()) throw createError({ statusCode: 400, message: '部门名称不能为空' })
if (!body.code?.trim()) throw createError({ statusCode: 400, message: '部门编码不能为空' })
// 检查编码唯一性
// const existing = await db.department.findUnique({ where: { code: body.code } })
// if (existing) throw createError({ statusCode: 409, message: '部门编码已存在' })
const newDept = {
id: Date.now(),
…body,
sort: body.sort || 0,
status: body.status || 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
// await db.department.create({ data: newDept })
return { code: 200, message: '创建成功', data: newDept }
})
// server/api/departments/[id].put.ts
import { defineEventHandler, readBody, getRouterParam, createError } from 'h3'
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'))
const body = await readBody<Partial<DeptCreateParams>>(event)
if (!id) throw createError({ statusCode: 400, message: '无效的部门ID' })
// 防止将部门设为自己的子部门
if (body.parentId === id) {
throw createError({ statusCode: 400, message: '不能将部门设为自己的子部门' })
}
const updated = {
id,
…body,
updatedAt: new Date().toISOString(),
}
// await db.department.update({ where: { id }, data: body })
return { code: 200, message: '更新成功', data: updated }
})
// server/api/departments/[id].delete.ts
import { defineEventHandler, getRouterParam, createError } from 'h3'
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'))
if (!id) throw createError({ statusCode: 400, message: '无效的部门ID' })
// 检查是否有子部门
// const children = await db.department.findMany({ where: { parentId: id } })
// if (children.length > 0) throw createError({ statusCode: 400, message: '该部门下存在子部门,无法删除' })
// 检查是否有员工
// const members = await db.user.findMany({ where: { departmentId: id } })
// if (members.length > 0) throw createError({ statusCode: 400, message: '该部门下存在员工,无法删除' })
// await db.department.delete({ where: { id } })
return { code: 200, message: '删除成功', data: null }
})
7.3 Pinia Store – 部门状态
// stores/department.ts
import { defineStore } from 'pinia'
export const useDepartmentStore = defineStore('department', {
state: () => ({
departments: [] as Department[],
currentDept: null as Department | null,
loading: false,
total: 0,
queryParams: {
keyword: '',
status: '',
page: 1,
pageSize: 20,
} as DeptQueryParams,
}),
getters: {
// 扁平化部门列表(用于下拉选择)
flatDepartments: (state) => {
const result: { id: number; name: string; level: number }[] = []
const flatten = (depts: Department[], level = 0) => {
depts.forEach((dept) => {
result.push({ id: dept.id, name: `${' '.repeat(level)}${dept.name}`, level })
if (dept.children?.length) flatten(dept.children, level + 1)
})
}
flatten(state.departments)
return result
},
},
actions: {
async fetchDepartments() {
this.loading = true
try {
const res = await $fetch<ApiResponse<{ list: Department[]; total: number }>>(
'/api/departments',
{ params: this.queryParams }
)
this.departments = res.data.list
this.total = res.data.total
} catch (error: any) {
ElMessage.error(error.data?.message || '获取部门列表失败')
} finally {
this.loading = false
}
},
async createDepartment(data: DeptCreateParams) {
const res = await $fetch<ApiResponse<Department>>('/api/departments', {
method: 'POST',
body: data,
})
ElMessage.success('创建成功')
await this.fetchDepartments()
return res.data
},
async updateDepartment(id: number, data: Partial<DeptCreateParams>) {
const res = await $fetch<ApiResponse<Department>>(`/api/departments/${id}`, {
method: 'PUT',
body: data,
})
ElMessage.success('更新成功')
await this.fetchDepartments()
return res.data
},
async deleteDepartment(id: number) {
await $fetch(`/api/departments/${id}`, { method: 'DELETE' })
ElMessage.success('删除成功')
await this.fetchDepartments()
},
},
})
7.4 部门管理页面
<!– pages/admin/departments.vue –>
<script setup lang="ts">
definePageMeta({ layout: 'admin', middleware: ['auth'] })
useSeoMeta({ title: '部门管理' })
const deptStore = useDepartmentStore()
const { departments, loading, queryParams } = storeToRefs(deptStore)
// 对话框状态
const dialogVisible = ref(false)
const dialogTitle = ref('新增部门')
const editingDept = ref<Department | null>(null)
const formRef = ref()
const form = reactive<DeptCreateParams>({
name: '',
code: '',
parentId: null,
leader: '',
phone: '',
email: '',
sort: 0,
status: 'active',
description: '',
})
const rules = {
name: [{ required: true, message: '请输入部门名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入部门编码', trigger: 'blur' }],
}
// 树形表格列配置
const columns = [
{ prop: 'name', label: '部门名称', minWidth: 200 },
{ prop: 'code', label: '部门编码', width: 120 },
{ prop: 'leader', label: '负责人', width: 100 },
{ prop: 'phone', label: '联系电话', width: 130 },
{ prop: 'sort', label: '排序', width: 80 },
{ prop: 'status', label: '状态', width: 100 },
{ prop: 'memberCount', label: '人数', width: 80 },
]
// 搜索
const handleSearch = () => {
queryParams.value.page = 1
deptStore.fetchDepartments()
}
// 重置
const handleReset = () => {
queryParams.value = { keyword: '', status: '', page: 1, pageSize: 20 }
deptStore.fetchDepartments()
}
// 打开新增对话框
const handleAdd = (parentId?: number) => {
dialogTitle.value = '新增部门'
editingDept.value = null
Object.assign(form, {
name: '', code: '', parentId: parentId || null,
leader: '', phone: '', email: '', sort: 0, status: 'active', description: '',
})
dialogVisible.value = true
}
// 打开编辑对话框
const handleEdit = (dept: Department) => {
dialogTitle.value = '编辑部门'
editingDept.value = dept
Object.assign(form, {
name: dept.name,
code: dept.code,
parentId: dept.parentId,
leader: dept.leader,
phone: dept.phone,
email: dept.email,
sort: dept.sort,
status: dept.status,
description: dept.description,
})
dialogVisible.value = true
}
// 提交表单
const handleSubmit = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
try {
if (editingDept.value) {
await deptStore.updateDepartment(editingDept.value.id, { …form })
} else {
await deptStore.createDepartment({ …form })
}
dialogVisible.value = false
} catch (error: any) {
ElMessage.error(error.data?.message || '操作失败')
}
}
// 删除
const handleDelete = async (dept: Department) => {
try {
await ElMessageBox.confirm(
`确定要删除部门「${dept.name}」吗?此操作不可恢复。`,
'删除确认',
{ type: 'warning', confirmButtonText: '确定删除', cancelButtonText: '取消' }
)
await deptStore.deleteDepartment(dept.id)
} catch {
// 用户取消
}
}
// 状态切换
const handleStatusChange = async (dept: Department) => {
const newStatus = dept.status === 'active' ? 'disabled' : 'active'
try {
await deptStore.updateDepartment(dept.id, { status: newStatus })
} catch {
// 回滚
}
}
// 初始化加载
onMounted(() => {
deptStore.fetchDepartments()
})
</script>
<template>
<div>
<!– 页面标题 –>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800">部门管理</h1>
<p class="text-gray-500 mt-1">管理公司组织架构,支持多级部门树形结构</p>
</div>
<!– 搜索栏 –>
<el-card class="mb-4" shadow="never">
<el-form :inline="true" :model="queryParams">
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="部门名称/编码"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable>
<el-option label="正常" value="active" />
<el-option label="停用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon> 搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon> 重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!– 操作栏 –>
<div class="mb-4">
<el-button type="primary" @click="handleAdd()">
<el-icon><Plus /></el-icon> 新增部门
</el-button>
</div>
<!– 树形表格 –>
<el-card shadow="never">
<el-table
v-loading="loading"
:data="departments"
row-key="id"
border
default-expand-all
:tree-props="{ children: 'children' }"
>
<el-table-column prop="name" label="部门名称" min-width="200" />
<el-table-column prop="code" label="部门编码" width="120" />
<el-table-column prop="leader" label="负责人" width="100" />
<el-table-column prop="phone" label="联系电话" width="130" />
<el-table-column prop="sort" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
:model-value="row.status === 'active'"
@change="handleStatusChange(row)"
active-text="正常"
inactive-text="停用"
inline-prompt
/>
</template>
</el-table-column>
<el-table-column prop="memberCount" label="人数" width="80" align="center" />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleAdd(row.id)">
添加子部门
</el-button>
<el-button link type="warning" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!– 新增/编辑对话框 –>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="上级部门">
<el-tree-select
v-model="form.parentId"
:data="departments"
:props="{ label: 'name', value: 'id', children: 'children' }"
placeholder="无(顶级部门)"
clearable
check-strictly
class="w-full"
/>
</el-form-item>
<el-form-item label="部门名称" prop="name">
<el-input v-model="form.name" placeholder="请输入部门名称" maxlength="50" />
</el-form-item>
<el-form-item label="部门编码" prop="code">
<el-input v-model="form.code" placeholder="如:TECH、MKT" maxlength="20" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="负责人">
<el-input v-model="form.leader" placeholder="请输入负责人" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="联系电话">
<el-input v-model="form.phone" placeholder="请输入联系电话" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" :max="999" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-radio-group v-model="form.status">
<el-radio value="active">正常</el-radio>
<el-radio value="disabled">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="部门描述(选填)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
7.5 可复用的 Composable
// composables/usePagination.ts
export function usePagination(fetchFn: (params: any) => Promise<void>) {
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0,
})
const handlePageChange = (page: number) => {
pagination.page = page
fetchFn({ page, pageSize: pagination.pageSize })
}
const handleSizeChange = (size: number) => {
pagination.pageSize = size
pagination.page = 1
fetchFn({ page: 1, pageSize: size })
}
return { pagination, handlePageChange, handleSizeChange }
}
// composables/useDepartment.ts
export function useDepartment() {
const store = useDepartmentStore()
const refreshList = () => store.fetchDepartments()
const getDeptName = (id: number | null) => {
if (!id) return '顶级部门'
const find = (depts: Department[]): string => {
for (const dept of depts) {
if (dept.id === id) return dept.name
if (dept.children) {
const name = find(dept.children)
if (name) return name
}
}
return ''
}
return find(store.departments) || '未知部门'
}
return { store, refreshList, getDeptName }
}
八、服务端认证中间件
// server/middleware/auth.ts
import { defineEventHandler, getCookie, createError } from 'h3'
export default defineEventHandler((event) => {
const url = event.path || ''
// 不需要认证的路径
const publicPaths = ['/api/auth/login', '/api/auth/register', '/api/public']
if (publicPaths.some((p) => url.startsWith(p))) return
// 只拦截 /api/ 路径
if (!url.startsWith('/api/')) return
const token = getCookie(event, 'access_token')
if (!token) {
throw createError({ statusCode: 401, message: '未授权,请先登录' })
}
const payload = verifyToken(token)
if (!payload) {
throw createError({ statusCode: 401, message: 'Token 已过期,请重新登录' })
}
// 将用户信息注入上下文
event.context.auth = payload
})
九、构建与部署
9.1 构建命令
# 开发模式
pnpm dev
# 生产构建(SSR 模式)
pnpm build
# 预览生产构建
pnpm preview
# 静态生成(SSG,适合纯内容站)
pnpm generate
9.2 Docker 部署
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install –frozen-lockfile
COPY . .
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
COPY –from=builder /app/.output ./.output
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
9.3 Nginx 反向代理配置
server {
listen 80;
server_name www.yourdomain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name www.yourdomain.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# 静态资源缓存
location /_nuxt/ {
proxy_pass http://127.0.0.1:3000;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, immutable";
}
# SSR 页面
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
9.4 PM2 进程管理
# ecosystem.config.cjs
module.exports = {
apps: [{
name: 'nuxt-app',
exec_mode: 'cluster',
instances: 'max',
script: './.output/server/index.mjs',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
}],
}
pm2 start ecosystem.config.cjs
pm2 save
pm2 startup
十、SEO/GEO 优化检查清单
| SSR 服务端渲染 | ssr: true | ✅ |
| 语义化 HTML | <header>/<main>/<article>/<section> | ✅ |
| 动态 Meta 标签 | useSeoMeta() / useHead() | ✅ |
| Canonical URL | <link rel="canonical"> | ✅ |
| Open Graph | og:title, og:image 等 | ✅ |
| 结构化数据 JSON-LD | Organization / FAQ / Breadcrumb | ✅ |
| Sitemap.xml | @nuxtjs/sitemap 模块 | ✅ |
| Robots.txt | @nuxtjs/robots + 手动配置 | ✅ |
| 预渲染关键页面 | nitro.prerender.routes | ✅ |
| 图片优化 | <NuxtImg> + WebP + lazy loading | ✅ |
| 页面加载速度 | 代码分割 + 资源压缩 + CDN | ✅ |
| AI 爬虫可访问 | robots.txt 允许 GPTBot/ClaudeBot | ✅ |
| FAQ 结构化 | FAQPage Schema(GEO 引用优化) | ✅ |
| 面包屑导航 | BreadcrumbList Schema | ✅ |
| 移动端适配 | 响应式布局 + viewport meta | ✅ |
总结
本方案基于 Nuxt 3 + Vue 3 + TypeScript + Pinia + Element Plus 技术栈,实现了:
项目可直接作为企业官网、管理后台、内容平台的基础模板使用。



