前置知识:@/ 是什么?
@/ 是路径别名,vite.config.ts 里配的:
resolve: {
alias: {
'@': '/src' // @ = src 目录
}
}
import { login } from '@/api/auth' // = src/api/auth.ts
import { useUserStore } from '@/stores/user' // = src/stores/user.ts
import type { UserInfo } from '@/types/api' // = src/types/api.d.ts
作用 :不用写 ../../../,@/ 始终指向 src/。
目录
-
[Axios 封装与请求拦截](#Axios 封装与请求拦截)
-
[Token 管理](#Token 管理)
-
[导入组件和函数的 6 种来源](#导入组件和函数的 6 种来源)
-
[CRUD 通用开发模式](#CRUD 通用开发模式)
-
[批量删除 + 单删统一处理](#批量删除 + 单删统一处理)
-
[生命周期 → onMounted](#生命周期 → onMounted)
1. 项目整体分层架构
核心思想
按职责分层,不要把所有代码塞一个文件里:
-
api/只管"怎么发请求",不关心页面长什么样 -
views/只管"页面长什么样 + 用户交互",不关心请求细节 -
stores/只管"全局数据",不关心 UI -
types/统一管数据类型,所有文件都引用同一套类型定义
目录结构
src/
├── api/ ← 每个数据模块一个 .ts 文件,只负责发 HTTP 请求
│ ├── request.ts ← axios 实例 + 拦截器(token、错误处理)
│ ├── auth.ts ← 登录/退出/刷新 token
│ └── employee.ts ← 员工 CRUD
├── components/ ← 公共组件(多个页面共用的 UI 片段)
├── views/ ← 页面组件(一个路由对应一个页面)
├── stores/ ← Pinia 状态管理(替代 Vuex)
├── router/ ← 路由表 + 路由守卫
├── types/ ← TS 类型定义
├── utils/ ← 纯工具函数(token 操作、日期格式化)
├── hooks/ ← 组合式函数(Vue 3 新特性,封装可复用的逻辑)
├── layout/ ← 布局框架(侧边栏 + 顶栏 + <router-view/>)
└── mock/ ← Mock 数据(后端没写好时前端自用)
2. Axios 封装与请求拦截
核心思想
不要直接用 axios.get(url),而是先 axios.create() 创建一个实例,给实例加请求拦截器 (自动带 token)和响应拦截器(统一报错 + 自动刷新 token)。
文件位置
src/api/request.ts ← 封装 axios 实例
src/utils/token.ts ← token 的读写操作
src/router/index.ts ← 路由实例(token 过期后跳登录页)
完整代码
// ===== api/request.ts =====
import axios from 'axios'
import { ElMessage } from 'element-plus'
import {
getAccessToken, setAccessToken,
getRefreshToken, setRefreshToken,
clearAllAuth,
} from '@/utils/token'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 30000,
})
// ===== 请求拦截器:自动挂 token =====
request.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// ===== Token 刷新相关 =====
let isRefreshing = false
let subscribers: Array<(token: string) => void> = []
// 独立的 axios 实例用于刷新 token(不走拦截器,避免死循环)
const refreshAxios = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
})
async function refreshAccessToken(): Promise<string> {
const refreshToken = getRefreshToken()
if (!refreshToken) throw new Error('无 refreshToken')
const res = await refreshAxios.post('/auth/refresh', { refreshToken })
if (res.data.code !== 200) throw new Error(res.data.message || '刷新失败')
const { accessToken, refreshToken: newRefresh } = res.data.data
setAccessToken(accessToken)
if (newRefresh) setRefreshToken(newRefresh)
return accessToken
}
// ===== 响应拦截器 =====
request.interceptors.response.use(
// 请求成功(状态码 2xx)
(response) => {
if (response.data.code !== 200) {
ElMessage.error(response.data.message || '请求失败')
return Promise.reject(new Error(response.data.message))
}
return response
},
// 请求失败(状态码非 2xx)
async (error) => {
const { config, response } = error
if (!response) {
ElMessage.error('网络连接异常,请检查网络')
return Promise.reject(error)
}
// 401 → token 过期,自动刷新
if (response.status === 401 && config && !config._retry) {
if (!isRefreshing) {
isRefreshing = true
config._retry = true
try {
const newToken = await refreshAccessToken()
isRefreshing = false
subscribers.forEach(cb => cb(newToken))
subscribers = []
config.headers.Authorization = `Bearer ${newToken}`
return request(config)
} catch (refreshError) {
isRefreshing = false
subscribers = []
clearAllAuth()
const router = (await import('@/router')).default
router.push('/login')
ElMessage.error('登录已过期,请重新登录')
return Promise.reject(refreshError)
}
} else {
// 有其他请求正在刷新 token,当前请求排队
return new Promise((resolve) => {
subscribers.push((newToken: string) => {
config.headers.Authorization = `Bearer ${newToken}`
resolve(request(config))
})
})
}
}
if (response.status === 403) {
ElMessage.error('权限不足,无法执行此操作')
return Promise.reject(error)
}
if (response.status >= 500) {
ElMessage.error('服务器异常,请稍后重试')
return Promise.reject(error)
}
ElMessage.error(response.data?.message || '请求失败')
return Promise.reject(error)
}
)
export default request
关键设计要点
| 设计 | 说明 |
|---|---|
永远只用 request 实例 |
不裸用 axios.get(),否则没有拦截器 |
| 请求拦截器统一挂 token | 写 100 个接口也只配一次 |
| 响应拦截器统一处理 401 | 双 token 自动刷新,用户无感知 |
| 并发请求排队 | isRefreshing 锁 + subscribers 队列,多个 401 只刷新一次 |
独立 refreshAxios 实例 |
刷新 token 的请求不走拦截器,防止死循环 |
3. Token 管理
存储策略
accessToken(短期,30min 过期)
→ 优先存内存(变量 accessTokenCache)
→ 安全:XSS 攻击窃取不到
→ 开发环境同步存 localStorage(方便 F5 刷新不重新登录)
refreshToken(长期,7 天过期)
→ 存 localStorage
→ 必须持久化,刷新页面后用 refreshToken 换新的 accessToken
完整代码
// ===== utils/token.ts =====
let accessTokenCache = ''
/** 获取 accessToken(内存优先 → localStorage 兜底) */
export function getAccessToken(): string {
return accessTokenCache || localStorage.getItem('access_token') || ''
}
/** 存储 accessToken */
export function setAccessToken(token: string): void {
accessTokenCache = token
if (import.meta.env.VITE_APP_ENV === 'development') {
localStorage.setItem('access_token', token)
}
}
/** 清除 accessToken */
export function removeAccessToken(): void {
accessTokenCache = ''
localStorage.removeItem('access_token')
}
/** 获取 refreshToken */
export function getRefreshToken(): string {
return localStorage.getItem('refresh_token') || ''
}
/** 存储 refreshToken */
export function setRefreshToken(token: string): void {
localStorage.setItem('refresh_token', token)
}
export function removeRefreshToken(): void {
localStorage.removeItem('refresh_token')
}
/** 一键清除(退出登录) */
export function clearAllAuth(): void {
removeAccessToken()
removeRefreshToken()
localStorage.removeItem('user_info')
}
安全设计
accessToken 放内存 → 安全,XSS 拿不到
refreshToken 放 localStorage → 持久化,刷新不丢
双 token 分工:accessToken 证明身份,refreshToken 用来换 accessToken
4. 路由守卫
核心思想
用户没登录 → 不管访问哪个页面 → 统一跳登录页。登录成功后 → 跳回原来想去的页面。
// ===== router/index.ts =====
import { useUserStore } from '@/stores/user'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
component: () => import('@/views/login/LoginView.vue'),
meta: { title: '登录', noAuth: true }, // 白名单
},
{
path: '/:pathMatch(.*)*', // 404
component: () => import('@/views/error/NotFoundView.vue'),
meta: { title: '404', noAuth: true },
},
// 其他页面默认需要登录
],
})
// 全局前置守卫
router.beforeEach((to, _from, next) => {
// 设置浏览器标签页标题
document.title = `${to.meta.title || ''} - ${import.meta.env.VITE_APP_TITLE}`
// 白名单直接放行
if (to.meta.noAuth) return next()
// 检查登录状态
const userStore = useUserStore()
if (!userStore.isLoggedIn) {
// 未登录 → 跳登录页,记住目标地址
return next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
}
next() // 已登录,放行
})
关键设计
| 设计 | 说明 |
|---|---|
meta.noAuth |
白名单机制,登录页/404 不需要检查 |
?redirect= |
登录后跳回用户本来想去的页面 |
() => import(...) |
路由懒加载,访问时才下载对应 JS |
document.title |
动态设置浏览器标签标题 |
5. 跨域代理配置
为什么需要代理
浏览器安全策略:http://localhost:9528 不能直接请求 http://localhost:8080
(端口不同 = 跨域)
解决办法:让 Vite 开发服务器做中间人转发
前端请求 → Vite Dev Server(9528) → 转发到后端(8080) → 返回数据
浏览器以为数据来自 9528
// ===== vite.config.ts =====
import { defineConfig } from 'vite'
export default defineConfig({
server: {
port: 9528,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true, // 修改请求头 Host 为目标地址
},
'/ws': {
target: 'ws://localhost:8080',
ws: true, // 开启 WebSocket 代理
},
},
},
})
注意: 代理只在开发环境生效。生产环境用 Nginx 做反向代理。
6. 多环境管理
文件说明
.env.development ← npm run dev 时加载
.env.production ← npm run build 时加载
.env.uat ← npm run build:uat 时加载
文件内容
# ===== .env.development =====
VITE_APP_ENV=development
VITE_APP_TITLE=外卖系统(DEV)
VITE_API_BASE_URL=/api # 开发用代理路径
VITE_WS_URL=/ws
VITE_USE_MOCK=true
# ===== .env.production =====
VITE_APP_ENV=production
VITE_APP_TITLE=外卖系统
VITE_API_BASE_URL=https://api.your-domain.com # 生产用真实域名
VITE_WS_URL=wss://api.your-domain.com/ws
VITE_USE_MOCK=false
代码中使用
// VITE_ 前缀的变量才会暴露给前端代码
const baseURL = import.meta.env.VITE_API_BASE_URL
// dev: /api prod: https://api.your-domain.com
const title = import.meta.env.VITE_APP_TITLE
// dev: 外卖系统(DEV) prod: 外卖系统
7. 导入组件和函数的 6 种来源
// ① npm 包(来自 node_modules)
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import axios from 'axios'
import { useRouter } from 'vue-router'
import { defineStore } from 'pinia'
import dayjs from 'dayjs'
// ② 自己写的工具函数(@/ = src/)
import { getAccessToken } from '@/utils/token'
import { login } from '@/api/auth'
import { useUserStore } from '@/stores/user'
// ③ 自己写的 .vue 组件
import App from './App.vue'
import MainLayout from '@/layout/MainLayout.vue'
// ④ 类型定义(编译时存在,运行时消失)
import type { UserInfo } from '@/types/api'
import type { FormInstance } from 'element-plus'
// ⑤ 静态资源
import logo from '@/assets/logo.png' // 图片 → 打包后的路径
import './assets/styles/global.scss' // 全局样式
// ⑥ Vite 虚拟模块
const env = import.meta.env.VITE_API_BASE_URL
口诀:
-
不带路径的 → npm 包
-
@/开头的 → 自己写的,@/=src/ -
./开头的 → 当前目录 -
import type→ 只导入类型,不产生运行时代码
8. CRUD 通用开发模式
五步法
第①步:页面头部 → 搜索框、下拉筛选、新增按钮
第②步:响应式数据 → ref / reactive,v-model 双向绑定
第③步:API 封装 → api/xxx.ts 里定义请求函数
第④步:事件绑定 → @click 调 API → 处理数据 → 更新页面
第⑤步:表格+分页 → el-table + el-pagination
完整示例(Vue 3 <script setup>)
<template>
<div class="page-container">
<!-- ① 页面头部 -->
<el-card shadow="never" class="search-card">
<el-form :model="query" inline>
<el-form-item label="员工姓名">
<el-input v-model="query.name" placeholder="请输入" clearable style="width:180px"
@keyup.enter="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">新增员工</el-button>
<span class="total-text">共 {{ total }} 条</span>
</div>
<!-- ⑤ 表格 + 分页 -->
<el-card shadow="never">
<el-table v-loading="loading" :data="tableData" stripe>
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="username" label="账号" width="120" />
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="createTime" label="创建时间" min-width="160" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button text type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > 0">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="handleSearch"
@current-change="handleSearch"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getEmployeeList, deleteEmployee } from '@/api/employee'
import type { Employee } from '@/types/api'
import { useRouter } from 'vue-router'
const router = useRouter()
const loading = ref(false)
const total = ref(0)
const tableData = ref<Employee[]>([])
const query = reactive({
page: 1,
pageSize: 10,
name: '',
})
async function loadData() {
loading.value = true
try {
const res = await getEmployeeList(query)
tableData.value = res.data.data.records
total.value = res.data.data.total
} catch {
// 拦截器已提示
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1 // ⚠️ 搜索时重置到第一页!
loadData()
}
function handleReset() {
query.name = ''
handleSearch()
}
function handleAdd() {
router.push('/employee/add')
}
function handleEdit(row: Employee) {
router.push({ path: '/employee/add', query: { id: row.id } })
}
async function handleDelete(row: Employee) {
try {
await ElMessageBox.confirm(`确定删除员工「${row.name}」吗?`, '删除确认', { type: 'warning' })
await deleteEmployee(row.id)
ElMessage.success('删除成功')
loadData()
} catch {
// 用户点取消,什么都不做
}
}
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
.page-container {
.search-card { margin-bottom: 12px; }
.toolbar {
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
.total-text { margin-left: auto; color: #909399; font-size: 13px; }
}
.pagination-wrap { display: flex; justify-content: flex-end; margin-top: 16px; }
}
</style>
关键点
| 要点 | 说明 |
|---|---|
| 搜索必须重置页码 | query.page = 1,否则搜出来的结果可能从第 3 页开始 |
loading 绑 v-loading |
网络慢时表格有加载动画 |
reactive 用于对象 |
script 里直接 query.page,不用 .value |
ref 用于基本类型和数组 |
script 里要 loading.value,模板里自动解包 |
9. 新增/编辑共用组件
核心思想
新增和编辑用同一个表单页面 ,通过 URL 参数 ?id=xxx 区分。代码只写一遍。
<template>
<div class="form-page">
<el-card shadow="never">
<template #header>
<span>{{ isEdit ? '编辑员工' : '新增员工' }}</span>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" style="max-width:600px">
<el-form-item label="账号" prop="username">
<el-input v-model="form.username" :disabled="isEdit" />
<!-- 编辑模式下账号不可修改 -->
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="submitForm">
{{ isEdit ? '保存修改' : '确认添加' }}
</el-button>
<el-button v-if="!isEdit" type="success" @click="submitAndContinue">
保存并继续添加
</el-button>
<el-button @click="router.back()">返回</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { addEmployee, updateEmployee, getEmployeeById } from '@/api/employee'
import type { FormInstance, FormRules } from 'element-plus'
const route = useRoute()
const router = useRouter()
// ===== 判断模式:注意!这里必须先判断再赋值 =====
const id = route.query.id
const isEdit = ref(!!id) // 有 id → 编辑模式
const editId = ref(id ? Number(id) : 0)
const saving = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
username: '',
name: '',
phone: '',
})
const rules: FormRules = {
username: [
{ required: true, message: '请输入账号', trigger: 'blur' },
],
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
],
}
onMounted(async () => {
if (isEdit.value) {
const res = await getEmployeeById(editId.value)
Object.assign(form, res.data.data) // 数据回显
}
})
async function submitForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (isEdit.value) {
await updateEmployee(editId.value, form)
ElMessage.success('修改成功')
} else {
await addEmployee(form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 拦截器已提示
} finally {
saving.value = false
}
}
async function submitAndContinue() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
await addEmployee(form)
ElMessage.success('新增成功')
Object.assign(form, { username: '', name: '', phone: '' }) // 重置表单
} catch {} finally {
saving.value = false
}
}
</script>
关键设计
| 设计 | 说明 |
|---|---|
route.query.id |
存在 → 编辑,不存在 → 新增 |
Object.assign(form, data) |
一行代码回显全部字段 |
:disabled="isEdit" |
编辑模式下账号不可修改 |
v-if="!isEdit" |
新增模式才显示"保存并继续添加" |
10. 批量删除 + 单删统一处理
核心思想
单个删除和批量删除用同一个函数处理,通过是否传入参数区分。
<template>
<div>
<el-button
type="danger"
:disabled="selectedRows.length === 0"
@click="handleDelete()">
<!-- 不传参数 = 批量删除 -->
批量删除
</el-button>
<el-table
v-loading="loading"
:data="tableData"
@selection-change="onSelectionChange">
<el-table-column type="selection" width="45" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="status" label="状态" />
<el-table-column label="操作" width="150">
<template #default="{ row }">
<!-- 传了 row 参数 = 单个删除 -->
<el-button text type="danger" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { getItemList, deleteItems } from '@/api/xxx'
// ===== 数据类型定义 =====
interface Item {
id: number
name: string
status: string
}
// ===== 表格数据 =====
const loading = ref(false)
const tableData = ref<Item[]>([])
const total = ref(0)
const query = reactive({
page: 1,
pageSize: 10,
})
async function loadData() {
loading.value = true
try {
const res = await getItemList(query)
tableData.value = res.data.data.records
total.value = res.data.data.total
} finally {
loading.value = false
}
}
// ===== 勾选逻辑 =====
const selectedRows = ref<Item[]>([])
function onSelectionChange(rows: Item[]) {
selectedRows.value = rows
}
// ===== 统一删除处理(单删 + 批删) =====
async function handleDelete(row?: Item) {
// 单删:传了 row → 取 [row.id]
// 批删:没传 row → 取 selectedRows 所有 id
const ids: number[] = row
? [row.id]
: selectedRows.value.map(r => r.id)
if (ids.length === 0) {
ElMessage.warning('请先选择要删除的数据')
return
}
try {
await ElMessageBox.confirm(
`确定删除选中的 ${ids.length} 条数据吗?`,
'删除确认',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
)
// 用户点了确定
await deleteItems(ids)
ElMessage.success(`成功删除 ${ids.length} 条数据`)
await loadData() // 刷新列表
selectedRows.value = [] // 清空勾选
} catch {
// 用户点取消 或 接口失败 → 什么都不做
}
}
onMounted(() => {
loadData()
})
</script>
关键点
| 要点 | 说明 |
|---|---|
单删传 row,批删不传 |
一个函数处理两种场景 |
selectedRows.map(r => r.id) |
提取 id 数组 |
:disabled="selectedRows.length === 0" |
无勾选时禁用按钮 |
ElMessageBox.confirm |
删除前必须确认 |
11. 文件上传
核心思想
el-upload 内部用的是自己的 HTTP 请求,不经过 axios 拦截器,必须手动传 token。
<template>
<el-upload
class="avatar-uploader"
:action="`${import.meta.env.VITE_API_BASE_URL}/common/upload`"
:headers="{ Authorization: `Bearer ${getAccessToken()}` }"
:show-file-list="false"
:on-success="handleSuccess"
:before-upload="beforeUpload"
>
<img v-if="imageUrl" :src="imageUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadProps } from 'element-plus'
import { getAccessToken } from '@/utils/token'
const imageUrl = ref('')
const handleSuccess: UploadProps['onSuccess'] = (response) => {
if (response.code === 200) {
imageUrl.value = response.data
} else {
ElMessage.error(response.message || '上传失败')
}
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
ElMessage.error('只能上传图片文件')
return false
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB')
return false
}
return true
}
</script>
最容易踩的坑
el-upload自己发请求 → 不经过 axios 拦截器 → token 不会自动带 → 必须:headers手动传
12. 状态切换(启停)
<template>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-switch
v-model="row.enabled"
@change="toggleStatus(row)"
/>
</template>
</el-table-column>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { toggleItemStatus } from '@/api/xxx'
async function toggleStatus(row: any) {
// 前置校验
if (row.username === 'admin') {
ElMessage.warning('管理员账号不可禁用')
row.enabled = !row.enabled // v-model 已改值,手动拨回来
return
}
const newEnabled = row.enabled
try {
await toggleItemStatus(row.id, newEnabled)
ElMessage.success(newEnabled ? '已启用' : '已禁用')
loadData()
} catch {
// 乐观更新回滚:接口失败,恢复原状态
row.enabled = !newEnabled
}
}
</script>
关键点
| 要点 | 说明 |
|---|---|
| 操作前校验 | admin 不可禁用,前端先挡一道 |
| 乐观更新 | 先让用户看到变化,接口失败再回滚 |
| v-model 顺序 | v-model 在 @change 之前就更新了值,失败时要手动回滚 |
13. 表单校验
<template>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="form.phone" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" />
</el-form-item>
</el-form>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
const formRef = ref<FormInstance>()
const form = reactive({
name: '',
phone: '',
email: '',
})
const rules: FormRules = {
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '姓名长度为 2-20 个字符', trigger: 'blur' },
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' },
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
}
async function submitForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
// 校验通过 → 发请求
}
</script>
校验规则速查
| 规则 | 写法 | 说明 |
|---|---|---|
| 必填 | { required: true, message: '...', trigger: 'blur' } |
不能为空 |
| 长度 | { min: 2, max: 20, message: '...', trigger: 'blur' } |
字符数范围 |
| 正则 | { pattern: /^1[3-9]\d{9}$/, message: '...', trigger: 'blur' } |
自定义格式 |
| 邮箱 | { type: 'email', message: '...', trigger: 'blur' } |
内置邮箱校验 |
| 自定义 | { validator: (rule, value, cb) => { ... }, trigger: 'blur' } |
复杂逻辑 |
必要条件
rules的 key、el-form-item的prop、form的字段名 三者必须一致。
14. 数据回显
编辑时先查详情,再填入表单:
onMounted(async () => {
const id = route.query.id
if (id) {
isEdit.value = true
editId.value = Number(id)
const res = await getDetailById(editId.value)
// Object.assign 批量赋值,一行替代几十行:
// form.name = res.data.data.name
// form.phone = res.data.data.phone
// form.email = res.data.data.email
Object.assign(form, res.data.data)
}
// id 不存在 → 新增模式,form 保持空值
})
15. 生命周期 → onMounted
<script setup lang="ts">
import { onMounted } from 'vue'
// <script setup> 顶层的代码在组件初始化时执行
// 但发请求需要等 DOM 挂载完,所以用 onMounted
onMounted(() => {
loadData() // 页面加载自动查数据
})
</script>
Vue 3 常用生命周期
直接在 <script setup> 顶层写代码 ← 代替 Vue 2 的 created
onMounted(() => { ... }) ← DOM 渲染完成,用于发请求、初始化图表
onBeforeUnmount(() => { ... }) ← 组件销毁前,清除定时器、取消订阅
onUnmounted(() => { ... }) ← 组件已销毁
核心思想一览
| 主题 | 一句话 |
|---|---|
| 分层架构 | api/ views/ stores/ types/ 各司其职,不越界 |
| axios 封装 | 实例 + 请求拦截器挂 token + 响应拦截器报错/刷新 |
| 双 token | accessToken 存内存(安全),refreshToken 存 localStorage(持久) |
| 404 刷新排队 | isRefreshing 锁 + subscribers 队列,多个 401 只刷新一次 |
| 路由守卫 | meta.noAuth 白名单 + ?redirect= 回跳 |
| 环境变量 | VITE_ 前缀,开发/生产自动切换 |
| CRUD 五步法 | 头部 → 响应式数据 → API → 事件 → 表格分页 |
| 新增编辑共用 | 同一页面,?id 区分模式 |
| 单删批删统一 | 传参判断,一个函数处理两种场景 |
| el-upload 手动传 token | 它不走 axios 拦截器 |
| 乐观更新 | 先切换状态,接口失败回滚 |
| 表单校验 | validate().catch(() => false),不崩 |
| Object.assign 回显 | 一行替代逐字段赋值 |
| onMounted 发请求 | 取代 Vue 2 的 created |