# Vue 3 + Element Plus 后台搭建完整记录
## 1. 概述
### (1)目标
为「好站站」企业建站引擎搭建一个现代化的后台管理系统,实现以下功能:
- 独立 SPA(单页应用)架构,提升用户体验
- 美观的登录页面和仪表盘布局
- API 认证(登录/退出),保障后台安全
- 可扩展的页面管理基础,便于后续功能迭代
### (2)技术栈选型及原因
| 模块 | 技术 | 版本 | 选型原因 |
|------|------|------|---------|
| 前端框架 | Vue 3 | 3.x | Composition API 让代码更清晰,TypeScript 支持好,生态成熟 |
| UI 组件库 | Element Plus | 最新 | 国内 Vue 3 后台事实标准,组件丰富,文档完善 |
| 状态管理 | Pinia | 最新 | Vue 3 官方推荐,比 Vuex 更轻量,API 更简洁 |
| 路由 | Vue Router 4 | 4.x | Vue 官方路由,支持 SPA,与 Vue 3 完美集成 |
| HTTP 客户端 | Axios | 最新 | 支持拦截器、请求取消,API 简洁 |
| 拖拽库 | vue-draggable-next | 最新 | 基于 Sortable.js,支持触摸屏,用于可视化拖拽编辑器 |
| 认证方案 | Laravel Sanctum | 最新 | Laravel 官方 API 认证,轻量且安全,开箱即用 |
| 构建工具 | Vite | 6.x | 编译速度快,热更新秒级响应,开发体验好 |
### (3)整体架构设计
```
用户访问 /admin
↓
Laravel 路由返回 admin.blade.php 视图
↓
Vite 加载编译后的 JS/CSS
↓
Vue Router 根据 URL 显示对应组件
↓
Pinia 管理登录状态
↓
Axios 调用 Laravel Sanctum API
↓
后端验证 Token,返回数据
```
### (4)路由设计
| 路径 | 组件 | 权限 | 说明 |
|------|------|------|------|
| `/admin/login` | Login.vue | 未登录可访问 | 登录页面 |
| `/admin` | MainLayout.vue + Dashboard.vue | 需要登录 | 仪表盘 |
| `/admin/pages` | MainLayout.vue + Pages.vue | 需要登录 | 页面管理 |
| `/admin/site` | MainLayout.vue + SiteSettings.vue | 需要登录 | 站点配置 |
### (5)认证流程设计
```
用户访问 /admin
↓
路由守卫检查 isAuthenticated
↓ 未登录
重定向到 /admin/login
↓
用户输入邮箱密码
↓
调用 authStore.login()
↓
Axios POST /api/admin/login
↓
后端验证成功,返回 Token
↓
Token 存入 localStorage
设置 Axios 默认 Authorization Header
↓
跳转到 /admin 仪表盘
```
---
## 2. 安装依赖
### (1)前端依赖安装
**执行命令**:
```bash
cd D:\laragon\www\engine-api
npm install vue@^3.5 vue-router@^4.5
npm install element-plus
npm install @element-plus/icons-vue
npm install pinia
npm install axios
npm install vue-draggable-next
npm install -D @vitejs/plugin-vue
```
**原因解释**:
| 依赖 | 作用 |
|------|------|
| `vue@^3.5` | Vue 3 核心框架 |
| `vue-router@^4.5` | Vue 官方路由,实现 SPA 页面切换 |
| `element-plus` | UI 组件库,提供表格、表单、弹窗等组件 |
| `@element-plus/icons-vue` | Element Plus 官方图标库 |
| `pinia` | 状态管理,管理用户登录状态和 Token |
| `axios` | HTTP 客户端,调用后端 API |
| `vue-draggable-next` | 拖拽库,用于可视化拖拽编辑器 |
| `@vitejs/plugin-vue` | Vite 官方 Vue 3 插件,让 Vite 识别 `.vue` 文件 |
### (2)后端依赖安装
**执行命令**:
```bash
composer require laravel/sanctum
php artisan vendor:publish --tag=sanctum-migrations
php artisan migrate
```
**原因解释**:
| 命令 | 作用 |
|------|------|
| `composer require laravel/sanctum` | 安装 Laravel Sanctum 认证包 |
| `php artisan vendor:publish --tag=sanctum-migrations` | 发布 Sanctum 的数据库迁移文件 |
| `php artisan migrate` | 执行迁移,生成 `personal_access_tokens` 表 |
**Sanctum 工作原理**:
- 用户登录成功后,后端生成唯一的 Token 返回给前端
- 前端将 Token 存储在 localStorage 中
- 后续请求在 HTTP Header 中携带 Token:`Authorization: Bearer {token}`
- 后端验证 Token 有效性,识别用户身份
---
## 3. 配置 Vite
### (1)文件路径
`D:\laragon\www\engine-api\vite.config.js`
### (2)完整配置代码
```javascript
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
laravel({
input: [
'resources/css/app.css',
'resources/js/app.js',
'resources/js/admin/main.js',
],
refresh: true,
}),
vue(),
],
});
```
### (3)配置解释
| 配置项 | 作用 | 原因 |
|--------|------|------|
| `laravel()` 插件 | 让 Vite 与 Laravel 集成,自动处理资源版本 | Laravel 官方推荐方式 |
| `input` 数组 | 定义三个入口文件 | 分离前端样式、前端主入口、后台主入口 |
| `vue()` 插件 | 让 Vite 支持 `.vue` 单文件组件 | Vue 3 官方插件 |
| `refresh: true` | 检测到 PHP/Blade 文件变化时自动刷新浏览器 | 提升开发体验 |
**设计原因**:后台使用独立入口 `resources/js/admin/main.js`,与前台分离,便于维护。
---
## 4. 创建后台目录结构
### (1)创建目录命令
```bash
cd resources/js
mkdir admin
cd admin
mkdir api
mkdir components
mkdir layouts
mkdir router
mkdir stores
mkdir views
```
### (2)最终目录结构
```
resources/js/admin/
├── api/ # API 请求封装
├── components/ # 公共组件
├── layouts/ # 布局组件(侧边栏、顶部栏)
├── router/ # 路由配置
├── stores/ # Pinia 状态管理
├── views/ # 页面组件
├── App.vue # 根组件
└── main.js # 入口文件
```
**设计原因**:这种目录结构是 Vue 3 后台项目的标准组织方式,职责清晰,便于团队协作和后期维护。
---
## 5. 创建后台入口文件
### (1)`main.js`
**文件路径**:`resources/js/admin/main.js`
**完整代码**:
```javascript
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import App from './App.vue';
import router from './router';
const app = createApp(App);
// 注册所有 Element Plus 图标组件
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
app.mount('#app');
```
**代码解释**:
| 代码 | 作用 | 原因 |
|------|------|------|
| `createApp(App)` | 创建 Vue 应用实例 | Vue 3 应用启动入口 |
| `createPinia()` | 创建状态管理实例 | Pinia 是 Vue 3 官方推荐的状态管理 |
| `router` | 注入路由 | 实现 SPA 页面切换 |
| `ElementPlus` | 注入 UI 组件库 | 使用 Element Plus 组件 |
| `app.component(key, component)` | 注册图标组件 | 可在模板中直接使用 `<el-icon><User /></el-icon>` |
| `app.mount('#app')` | 挂载到 DOM 元素 | Vue 应用接管页面中的 `#app` 容器 |
### (2)`App.vue`
**文件路径**:`resources/js/admin/App.vue`
**完整代码**:
```vue
<template>
<router-view />
</template>
<script setup>
import { onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
const authStore = useAuthStore();
onMounted(() => {
authStore.checkAuth();
});
</script>
```
**代码解释**:
| 代码 | 作用 | 原因 |
|------|------|------|
| `<router-view />` | 路由出口,根据 URL 显示不同页面 | Vue Router 的核心组件 |
| `onMounted()` | 组件挂载后执行的生命周期钩子 | 确保 DOM 已渲染后再执行逻辑 |
| `authStore.checkAuth()` | 检查用户登录状态 | 页面刷新时验证 Token 是否有效 |
---
## 6. 配置路由
### (1)路由文件
**文件路径**:`resources/js/admin/router/index.js`
**完整代码**:
```javascript
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
const routes = [
{
path: '/admin/login',
name: 'login',
component: () => import('../views/Login.vue'),
meta: { guest: true },
},
{
path: '/admin',
component: () => import('../layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'dashboard',
component: () => import('../views/Dashboard.vue'),
},
{
path: 'pages',
name: 'pages',
component: () => import('../views/Pages.vue'),
},
{
path: 'site',
name: 'site-settings',
component: () => import('../views/SiteSettings.vue'),
},
],
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// 路由守卫:在每次路由跳转前执行权限检查
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
const isAuthenticated = authStore.isAuthenticated;
if (to.meta.requiresAuth && !isAuthenticated) {
next('/admin/login');
} else if (to.meta.guest && isAuthenticated) {
next('/admin');
} else {
next();
}
});
export default router;
```
**代码解释**:
| 代码 | 作用 | 原因 |
|------|------|------|
| `createWebHistory()` | 使用 HTML5 历史模式 | URL 不带 `#` 号,更美观 |
| `meta: { guest: true }` | 标记为「游客页面」 | 只有未登录用户才能访问 |
| `meta: { requiresAuth: true }` | 标记为「需认证页面」 | 必须登录才能访问 |
| `children` 数组 | 嵌套路由 | 共用同一个布局组件(MainLayout) |
| `() => import(...)` | 动态导入组件 | 实现代码分割,减少首屏加载体积 |
| `router.beforeEach` | 全局前置守卫 | 在跳转前检查权限,决定是否允许访问 |
**路由守卫逻辑**:
```
访问 /admin/dashboard
↓
检查 requiresAuth: true
↓
isAuthenticated 是否 true?
↓ 否
重定向到 /admin/login
↓ 是
正常访问页面
```
---
## 7. 创建认证 Store
### (1)`auth.js`
**文件路径**:`resources/js/admin/stores/auth.js`
**完整代码**:
```javascript
import { defineStore } from 'pinia';
import axios from 'axios';
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: localStorage.getItem('token'),
}),
getters: {
isAuthenticated: (state) => !!state.token,
},
actions: {
async login(email, password) {
try {
const response = await axios.post('/api/admin/login', {
email,
password,
});
this.token = response.data.token;
this.user = response.data.user;
localStorage.setItem('token', this.token);
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
return true;
} catch (error) {
return false;
}
},
async logout() {
try {
await axios.post('/api/admin/logout');
} catch (error) {
// 忽略退出请求失败,仍然清除本地状态
}
this.token = null;
this.user = null;
localStorage.removeItem('token');
delete axios.defaults.headers.common['Authorization'];
},
async checkAuth() {
if (!this.token) return;
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
try {
const response = await axios.get('/api/admin/user');
this.user = response.data;
} catch {
this.logout();
}
},
},
});
```
**代码解释**:
| 模块 | 代码 | 作用 | 原因 |
|------|------|------|------|
| state | `user: null` | 存储用户信息 | 用于页面显示用户名称 |
| state | `token: localStorage.getItem('token')` | 从 localStorage 读取 token | 页面刷新后恢复登录状态 |
| getter | `isAuthenticated: (state) => !!state.token` | 计算是否已登录 | 用于路由守卫判断 |
| action | `login()` | 发送登录请求,保存 token | 核心认证逻辑 |
| action | `logout()` | 清除 token 和用户信息 | 退出登录 |
| action | `checkAuth()` | 验证 token 有效性 | 页面刷新时自动验证 |
**Token 存储位置选择原因**:
| 存储位置 | 优点 | 缺点 | 选择 |
|----------|------|------|------|
| localStorage | 持久化存储,页面刷新不丢失 | 可能受 XSS 攻击 | ✅ 采用(配合 CSP) |
| sessionStorage | 标签页关闭即清除 | 用户体验差 | ❌ 不选 |
| Cookie | 自动携带 | CSRF 风险 | ❌ 不选 |
---
## 8. 创建登录页面
### (1)`Login.vue`
**文件路径**:`resources/js/admin/views/Login.vue`
**完整代码**:
```vue
<template>
<div>
<el-card>
<template #header>
<div>
<h2>好站站后台管理</h2>
<p>登录开始建站</p>
</div>
</template>
<el-form :model="form" :rules="rules" ref="formRef">
<el-form-item prop="email">
<el-input
v-model="form.email"
placeholder="邮箱"
:prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
:prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
style="width: 100%"
>
登录
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { User, Lock } from '@element-plus/icons-vue';
import { useAuthStore } from '../stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const formRef = ref();
const loading = ref(false);
const form = reactive({
email: '',
password: '',
});
const rules = {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
};
const handleLogin = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
loading.value = true;
const success = await authStore.login(form.email, form.password);
loading.value = false;
if (success) {
ElMessage.success('登录成功');
router.push('/admin');
} else {
ElMessage.error('邮箱或密码错误');
}
});
};
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
}
.login-header {
text-align: center;
}
</style>
```
**代码解释**:
| 代码 | 作用 | 原因 |
|------|------|------|
| `el-card` | 卡片容器 | 让登录表单有层次感 |
| `el-form` + `rules` | 表单验证 | 前端校验,减少无效请求 |
| `:loading="loading"` | 按钮加载状态 | 防止重复提交 |
| `ref="formRef"` | 表单引用 | 调用表单验证方法 |
| `show-password` | 密码显示切换 | 用户体验优化 |
| `linear-gradient` 渐变背景 | 视觉美化 | 提升品牌感 |
---
## 9. 创建后台布局
### (1)`MainLayout.vue`
**文件路径**:`resources/js/admin/layouts/MainLayout.vue`
**完整代码**:
```vue
<template>
<el-container>
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'">
<div>
<span v-if="!isCollapse">好站站</span>
<span v-else>好</span>
</div>
<el-menu
:collapse="isCollapse"
router
:default-active="$route.path"
background-color="#001529"
text-color="#bfbfbf"
active-text-color="#fff"
>
<el-menu-item index="/admin">
<el-icon><Odometer /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/admin/pages">
<el-icon><Document /></el-icon>
<span>页面管理</span>
</el-menu-item>
<el-menu-item index="/admin/site">
<el-icon><Setting /></el-icon>
<span>站点配置</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<!-- 顶部栏 -->
<el-header>
<div>
<el-icon @click="toggleCollapse">
<Fold v-if="!isCollapse" />
<Expand v-else />
</el-icon>
</div>
<div>
<el-dropdown @command="handleCommand">
<span>
{{ authStore.user?.name }}
<el-icon><CaretBottom /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主内容区 -->
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '../stores/auth';
import {
Odometer,
Document,
Setting,
Fold,
Expand,
CaretBottom,
} from '@element-plus/icons-vue';
const router = useRouter();
const authStore = useAuthStore();
const isCollapse = ref(false);
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value;
};
const handleCommand = async (command) => {
if (command === 'logout') {
await authStore.logout();
ElMessage.success('已退出登录');
router.push('/admin/login');
}
};
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.aside {
background-color: #001529;
transition: width 0.3s;
}
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
font-weight: bold;
background-color: #002140;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
border-bottom: 1px solid #e8e8e8;
padding: 0 20px;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
}
.user-info {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.main {
background-color: #f0f2f5;
padding: 20px;
}
</style>
```
**代码解释**:
| 代码 | 作用 | 原因 |
|------|------|------|
| `el-container` + `el-aside` + `el-header` + `el-main` | 经典后台布局 | Element Plus 提供的布局组件 |
| `:collapse="isCollapse"` | 侧边栏折叠状态 | 节省屏幕空间 |
| `router` 属性 | 启用路由模式 | 点击菜单自动跳转 |
| `:default-active="$route.path"` | 高亮当前菜单 | 用户知道自己在哪个页面 |
| `el-dropdown` | 用户下拉菜单 | 放置退出登录等操作 |
| `authStore.user?.name` | 可选链操作符 | 防止 user 为 null 时报错 |
| `toggleCollapse` | 切换折叠状态 | 用户体验优化 |
**布局结构**:
```
┌─────────────────────────────────────────────────────────┐
│ ┌──────┐ ┌─────────────────────────────────────────┐ │
│ │ 好站 │ │ 折叠按钮 用户 ▼ │ │
│ │ 站 │ ├─────────────────────────────────────────┤ │
│ ├──────┤ │ │ │
│ │ 仪表 │ │ │ │
│ │ 盘 │ │ 主内容区 │ │
│ ├──────┤ │ (router-view) │ │
│ │ 页面 │ │ │ │
│ │ 管理 │ │ │ │
│ ├──────┤ │ │ │
│ │ 站点 │ │ │ │
│ │ 配置 │ │ │ │
│ └──────┘ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
---
## 10. 创建仪表盘页面
### (1)`Dashboard.vue`
**文件路径**:`resources/js/admin/views/Dashboard.vue`
**完整代码**:
```vue
<template>
<div>
<el-row :gutter="20">
<el-col :span="6">
<el-card>
<div>{{ pageCount }}</div>
<div>页面总数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div>0</div>
<div>组件总数</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div>0</div>
<div>今日访问</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div>v1.0</div>
<div>系统版本</div>
</el-card>
</el-col>
</el-row>
<el-card style="margin-top: 20px">
<h3>欢迎使用好站站</h3>
<p>点击左侧菜单开始建站,或查看使用文档了解更多功能。</p>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
const pageCount = ref(0);
onMounted(async () => {
try {
const response = await axios.get('/api/admin/pages');
pageCount.value = response.data.length || 0;
} catch (error) {
console.error('获取页面统计失败', error);
}
});
</script>
<style scoped>
.stat-card {
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #1890ff;
}
.stat-label {
color: #666;
margin-top: 10px;
}
.welcome-card h3 {
margin-bottom: 10px;
}
.welcome-card p {
color: #666;
}
</style>
```
**代码解释**:
| 代码 | 作用 | 原因 |
|------|------|------|
| `el-row` + `el-col` | 栅格布局 | 响应式,4 列等宽 |
| `:gutter="20"` | 列间距 | 卡片之间有间隔 |
| `:span="6"` | 每列占 6/24 | 一行 4 列(24 ÷ 4 = 6) |
| `onMounted` | 组件挂载后请求数据 | 页面加载时获取统计数据 |
| `pageCount.value` | 响应式数据 | 数据变化时自动更新视图 |
### (2)创建占位页面
**文件路径**:`resources/js/admin/views/Pages.vue`
```vue
<template>
<el-card>
<h3>页面管理</h3>
<p>页面管理功能开发中...</p>
</el-card>
</template>
```
**文件路径**:`resources/js/admin/views/SiteSettings.vue`
```vue
<template>
<el-card>
<h3>站点配置</h3>
<p>站点配置功能开发中...</p>
</el-card>
</template>
```
**设计原因**:先创建占位页面,确保路由跳转不会 404,后续再填充实际功能。
---
## 11. 配置后端 API
### (1)配置 Sanctum
**文件路径**:`config/sanctum.php`
找到 `stateful` 字段,修改为:
```php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
```
**设计原因**:
| 设计点 | 原因 |
|--------|------|
| 不写死域名 | 使用 `Sanctum::currentApplicationUrlWithPort()` 动态获取 `APP_URL` |
| 用户安装适配 | 安装向导写入 `APP_URL` 到 `.env`,Sanctum 自动读取 |
| 本地开发兼容 | 保留 localhost、127.0.0.1 等本地域名 |
### (2)修改 `bootstrap/app.php`
**文件路径**:`bootstrap/app.php`
```php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->api(prepend: [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
```
**代码解释**:
| 代码 | 作用 | 原因 |
|------|------|------|
| `api: __DIR__.'/../routes/api.php'` | 注册 API 路由 | 确保 API 路由被加载 |
| `$middleware->api(prepend: [...])` | 在 API 中间件组最前面添加 Sanctum 中间件 | 确保 API 请求先经过 Sanctum 认证 |
### (3)修改 User 模型
**文件路径**:`app/Models/User.php`
```php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasApiTokens;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
```
**设计原因**:`HasApiTokens` trait 提供了 `createToken()` 方法,用于生成 API Token。如果不添加,调用 `$user->createToken()` 会报 `Call to undefined method` 错误。
### (4)创建 AuthController
**执行命令**:
```bash
php artisan make:controller Admin/AuthController
```
**文件路径**:`app/Http/Controllers/Admin/AuthController.php`
**完整代码**:
```php
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AuthController extends Controller
{
public function login(Request $request)
{
// 验证请求参数
$credentials = $request->validate([
'email' => 'required|email',
'password' => 'required',
]);
// 尝试登录
if (!Auth::attempt($credentials)) {
return response()->json([
'message' => '邮箱或密码错误'
], 401);
}
// 获取用户并生成 Token
$user = Auth::user();
$token = $user->createToken('admin-token')->plainTextToken;
return response()->json([
'token' => $token,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
]);
}
public function user(Request $request)
{
return response()->json($request->user());
}
public function logout(Request $request)
{
// 删除当前访问 Token
$request->user()->currentAccessToken()->delete();
return response()->json(['message' => '已退出']);
}
}
```
**代码解释**:
| 方法 | 路由 | 作用 | 认证要求 |
|------|------|------|---------|
| `login` | POST /api/admin/login | 验证邮箱密码,返回 Token | 无 |
| `user` | GET /api/admin/user | 获取当前登录用户信息 | 需要 Token |
| `logout` | POST /api/admin/logout | 删除 Token,退出登录 | 需要 Token |
### (5)配置 API 路由
**文件路径**:`routes/api.php`
```php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Admin\AuthController;
use App\Http\Controllers\Admin\PageController;
use App\Http\Controllers\Admin\SiteController;
// 无需认证的路由
Route::post('/admin/login', [AuthController::class, 'login']);
// 需要认证的路由(使用 auth:sanctum 中间件)
Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
Route::get('/user', [AuthController::class, 'user']);
Route::post('/logout', [AuthController::class, 'logout']);
// 站点配置
Route::get('/site', [SiteController::class, 'index']);
Route::put('/site', [SiteController::class, 'update']);
// 页面管理
Route::get('/pages', [PageController::class, 'index']);
Route::post('/pages', [PageController::class, 'store']);
Route::get('/pages/{id}', [PageController::class, 'show']);
Route::put('/pages/{id}', [PageController::class, 'update']);
Route::delete('/pages/{id}', [PageController::class, 'destroy']);
});
```
**设计原因**:
- `auth:sanctum` 中间件会自动验证请求 Header 中的 `Authorization: Bearer {token}`
- `prefix('admin')` 将所有路由前缀加上 `/admin`,方便管理
- 无需认证的路由只有登录接口,其他都需要 Token
### (6)创建 PageController 和 SiteController
**执行命令**:
```bash
php artisan make:controller Admin/PageController
php artisan make:controller Admin/SiteController
```
**文件路径**:`app/Http/Controllers/Admin/PageController.php`
```php
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Page;
use Illuminate\Http\Request;
class PageController extends Controller
{
public function index()
{
$pages = Page::with('site')->get();
return response()->json($pages);
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:pages',
'is_home' => 'boolean',
'status' => 'boolean',
]);
$page = Page::create($validated);
return response()->json($page, 201);
}
public function show($id)
{
$page = Page::with('components')->findOrFail($id);
return response()->json($page);
}
public function update(Request $request, $id)
{
$page = Page::findOrFail($id);
$validated = $request->validate([
'title' => 'string|max:255',
'slug' => 'string|max:255|unique:pages,slug,' . $id,
'is_home' => 'boolean',
'status' => 'boolean',
]);
$page->update($validated);
return response()->json($page);
}
public function destroy($id)
{
$page = Page::findOrFail($id);
$page->delete();
return response()->json(null, 204);
}
}
```
**文件路径**:`app/Http/Controllers/Admin/SiteController.php`
```php
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Site;
use Illuminate\Http\Request;
class SiteController extends Controller
{
public function index()
{
$site = Site::first();
return response()->json($site);
}
public function update(Request $request)
{
$site = Site::first();
$validated = $request->validate([
'site_name' => 'string|max:255',
'site_logo' => 'string|max:500',
'site_keywords' => 'string|max:500',
'site_description' => 'string|nullable',
]);
if ($site) {
$site->update($validated);
} else {
$site = Site::create($validated);
}
return response()->json($site);
}
}
```
**设计原因**:
- `PageController` 提供页面的 CRUD 操作
- `SiteController` 提供站点配置的获取和更新
- `with('site')` 和 `with('components')` 是 Eloquent 关联查询,减少数据库查询次数
- `findOrFail` 找不到记录时自动返回 404
---
## 12. 创建后台视图和路由
### (1)后台视图
**文件路径**:`resources/views/admin.blade.php`
```blade
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>好站站后台管理</title>
@vite('resources/js/admin/main.js')
</head>
<body>
<div id="app"></div>
</body>
</html>
```
**设计原因**:
- `@vite('resources/js/admin/main.js')` 是 Laravel Vite 指令,会自动引入编译后的 JS 和 CSS
- `<div id="app"></div>` 是 Vue 应用的挂载点
- 所有后台页面共用同一个视图,由 Vue Router 控制具体显示内容
### (2)添加后台路由
**文件路径**:`routes/web.php`
在文件末尾添加:
```php
// 后台 SPA 路由(必须放在最后,避免拦截其他路由)
Route::get('/admin/{any?}', function () {
return view('admin');
})->where('any', '.*');
```
**设计原因**:
- `where('any', '.*')` 匹配所有 `/admin/*` 路径
- 必须放在路由文件最后,避免拦截其他具体路由(如 `/admin/login` 会被前端 Vue Router 处理)
- 所有后台请求都返回同一个视图,由 Vue Router 接管路由
---
## 13. 编译和访问
### (1)编译前端资源
**执行命令**:
```bash
npm run build
```
**预期输出**:
```
vite v6.4.2 building for production...
✓ 1672 modules transformed.
public/build/manifest.json
public/build/assets/main-CxRXhp-G.js
public/build/assets/main-Cahm-8S9.css
...
```
**设计原因**:Vite 会将 `resources/js/admin/main.js` 及其所有依赖编译成浏览器可识别的 JS/CSS 文件,输出到 `public/build` 目录。编译后的文件需要提交到 Git,用户下载后直接使用,不需要安装 Node.js。
### (2)访问后台
浏览器打开:`http://engine-api.test/admin`
**预期结果**:
- 自动跳转到 `http://engine-api.test/admin/login`
- 显示登录页面(紫色渐变背景)
- 输入管理员账号密码可登录
- 登录成功后跳转到仪表盘
---
## 14. 对一键安装的影响
### (1)影响分析
| 影响项 | 说明 | 状态 |
|--------|------|------|
| Sanctum 域名配置 | 使用动态获取,不写死 | ✅ 无影响 |
| 编译文件 | 提交到 Git,用户无需编译 | ✅ 无影响 |
| 后台路由 | 独立于安装路由 | ✅ 无影响 |
| 用户安装 | 安装完成后直接访问 `/admin` | ✅ 正常 |
### (2)确保事项
| 事项 | 处理方式 |
|------|---------|
| `.env` 中的 `APP_URL` 必须正确 | 安装向导已处理 |
| 编译后的前端文件必须存在 | 提交 `public/build/` 到 Git |
| Sanctum 配置动态读取域名 | 已修改为 `Sanctum::currentApplicationUrlWithPort()` |
---
## 15. 文件清单
```
resources/js/admin/
├── api/ # API 请求封装(待完善)
├── components/ # 公共组件(待完善)
├── layouts/
│ └── MainLayout.vue # 后台主布局
├── router/
│ └── index.js # 路由配置
├── stores/
│ └── auth.js # 认证状态管理
├── views/
│ ├── Login.vue # 登录页面
│ ├── Dashboard.vue # 仪表盘
│ ├── Pages.vue # 页面管理(占位)
│ └── SiteSettings.vue # 站点配置(占位)
├── App.vue # 根组件
└── main.js # 入口文件
app/Http/Controllers/Admin/
├── AuthController.php # 认证控制器
├── PageController.php # 页面管理控制器
└── SiteController.php # 站点配置控制器
routes/
├── api.php # API 路由
└── web.php # Web 路由(添加了 /admin/{any?})
resources/views/
└── admin.blade.php # 后台视图
config/
└── sanctum.php # Sanctum 配置(修改了 stateful)
```
---