外卖项目总结下 (前端板块)

前置知识:@/ 是什么?

@/ 是路径别名,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/


目录

  1. 项目整体分层架构

  2. [Axios 封装与请求拦截](#Axios 封装与请求拦截)

  3. [Token 管理](#Token 管理)

  4. 路由守卫

  5. 跨域代理配置

  6. 多环境管理

  7. [导入组件和函数的 6 种来源](#导入组件和函数的 6 种来源)

  8. [CRUD 通用开发模式](#CRUD 通用开发模式)

  9. 新增/编辑共用组件

  10. [批量删除 + 单删统一处理](#批量删除 + 单删统一处理)

  11. 文件上传

  12. 状态切换(启停)

  13. 表单校验

  14. 数据回显

  15. [生命周期 → 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 页开始
loadingv-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-itempropform 的字段名 三者必须一致


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
相关推荐
liming4951 小时前
Maven中央库迁移
服务器·前端·maven
超哥--8 小时前
B站视频内容智能分析系统(九):React 前端与管理面板
前端·react.js·前端框架
Cutecat_11 小时前
视频字幕处理工具横向:提取模式 vs 编辑模式,该如何选择
android·前端·ios·语音识别
qq_4221525711 小时前
PDF 加水印工具怎么选?2026 年文档版权保护方案对比
前端·pdf·github
kyriewen12 小时前
手写 Promise.all、race、any:不到 30 行代码,解决并发异步的所有姿势
前端·javascript·面试
brucelee18613 小时前
OpenClaw 浏览器控制(Chrome MCP)完整教程
前端·chrome
ct97813 小时前
React 状态管理方案深度对比
开发语言·前端·react
胡志辉的博客13 小时前
深入浅出理解浏览器事件循环:从一道输出题讲到 Chrome 源码
前端·javascript·chrome·chromium·event loop
代码不加糖13 小时前
js中不会冒泡的事件有哪些?
前端·javascript·vue.js