欢迎光临
我们一直在努力

Nuxt.js 框架,前端SSR项目方便SEO/GEO, 实践

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 技术栈,实现了:

  • SEO 优化:SSR 渲染 + 动态 Meta + Sitemap + 结构化数据,确保搜索引擎完整抓取
  • GEO 优化:JSON-LD 结构化数据 + 语义化 HTML + AI 爬虫白名单,提升在 AI 生成式引擎中的引用率
  • 登录注册:JWT + HttpOnly Cookie + 路由守卫 + 表单校验,完整认证流程
  • 部门管理:树形 CRUD + 状态管理 + 权限控制,企业级后台管理功能
  • 项目可直接作为企业官网、管理后台、内容平台的基础模板使用。

    赞(0)
    未经允许不得转载:171主机测评 » Nuxt.js 框架,前端SSR项目方便SEO/GEO, 实践
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址