九、【前后端联调篇】Vue3 + Axios 异步通信实战
九、【前后端联调篇】Vue3 + Axios 异步通信实战
- 前言
- 准备工作
- 第一步:安装 Axios
- 第二步:封装 Axios 实例
- 第三步:创建 API 服务模块
- 第四步:在组件中调用 API
- 第五步:测试前后端联调
- 总结
前言
在 Web 开发中,前后端分离架构已成为主流。前端负责用户界面和交互,后端负责业务逻辑和数据处理。它们之间通过 API(通常是 RESTful API)进行通信。
- 前端需要向后端发送请求来:
- 获取数据(GET请求),如获取项目列表、用例详情等。
- 创建新数据(POST请求),如新建项目、提交表单等。
- 更新数据(PUT/PATCH请求),如修改用例信息等。
- 删除数据(DELETE请求),如删除模块等。
- 后端接收请求,处理后返回响应给前端:
- 响应通常是 JSON 格式的数据。
- 响应中还包含状态码,用于指示请求的处理结果。
为什么选择 Axios?
Axios 是一个基于 Promise 的 HTTP 客户端,可以用在浏览器和 Node.js 中。它拥有许多优秀的特性:
- API 简洁易用: 发送各种类型的 HTTP 请求非常方便。
- 支持 Promise API: 天然支持
async/await
,使得异步代码更易读写。 - 请求和响应拦截器: 可以在请求发送前或响应处理前进行全局的预处理,如添加 Token、统一错误处理等。
- 自动转换 JSON 数据: 默认情况下,它会自动将请求数据序列化为 JSON 字符串,并将响应数据解析为 JavaScript 对象。
- 客户端支持防御 XSRF: 增强安全性。
- 浏览器兼容性好。
准备工作
-
前端项目已就绪:
test-platform/frontend
项目可以正常运行 (npm run dev
)。
-
后端 API 运行: 确保你的 Django 后端开发服务器 (
python manage.py runserver
) 可运行,并且 API (http://127.0.0.1:8000/api/...
) 可以正常访问。
-
Pinia 用户状态管理已配置: 我们将使用
userStore
中的 Token 来演示如何在请求头中添加认证信息。
第一步:安装 Axios
如果你的项目中还没有 Axios,首先需要安装它。
在前端项目根目录 (test-platform/frontend
) 下打开终端,运行:
npm install axios --save
这会将 Axios 添加到你的项目依赖中。
第二步:封装 Axios 实例
为了更好地管理 API 请求,通常我们会创建一个 Axios 实例,并对其进行一些全局配置,而不是在每个组件中都直接使用 axios.get(...)
。
-
创建
utils/request.ts
文件:
在src
目录下创建一个utils
文件夹,并在其中创建一个request.ts
文件。
-
编写
request.ts
:
// test-platform/frontend/src/utils/request.ts import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios' import { ElMessage, ElMessageBox } from 'element-plus' import { useUserStore } from '@/stores/user' // 引入 user store// 创建 Axios 实例 const service: AxiosInstance = axios.create({// 1. 基础配置baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // API 的 base_url, 从 .env 文件读取timeout: 10000, // 请求超时时间 (毫秒)headers: {'Content-Type': 'application/json;charset=utf-8'} })// 2. 请求拦截器 (Request Interceptor) service.interceptors.request.use((config: InternalAxiosRequestConfig) => {// 在发送请求之前做些什么const userStore = useUserStore()if (userStore.token) {// 让每个请求携带自定义 token// 请根据实际情况修改这里的 Token 格式,例如 'Bearer ' + tokenconfig.headers.Authorization = `Bearer ${userStore.token}`}console.log('Request config:', config) // 调试用return config},(error) => {// 对请求错误做些什么console.error('Request Error:', error) // for debugreturn Promise.reject(error)} )// 3. 响应拦截器 (Response Interceptor) service.interceptors.response.use((response: AxiosResponse) => {// 对响应数据做点什么// HTTP 状态码为 2xx 时会进入这里const res = response.dataconsole.log('Response data:', res) // 调试用// 这里可以根据后端返回的自定义 code/status 来判断业务成功或失败// 例如,如果后端约定 code === 0 表示成功// if (res.code !== 0) {// ElMessage({// message: res.message || 'Error',// type: 'error',// duration: 5 * 1000// })// // 可以根据不同的业务错误码进行特定处理// return Promise.reject(new Error(res.message || 'Error'))// } else {// return res // 只返回 data 部分// }// 对于我们的 DRF 后端,通常 2xx 状态码就表示业务成功,直接返回响应数据return response // 或者 return res 如果你只想取 data},(error) => {// 超出 2xx 范围的状态码都会触发该函数。// 对响应错误做点什么console.error('Response Error:', error.response || error.message) // for debugconst userStore = useUserStore() // 在错误处理中也可能需要访问 storeif (error.response) {const { status, data } = error.responselet message = `请求错误 ${status}: `if (data && typeof data === 'object' && data.detail) {message += data.detail; // DRF 认证失败等通常在 detail 中} else if (data && typeof data === 'string') {message += data;} else if (error.message) {message = error.message;} else {message += '未知错误';}if (status === 401) {// 例如:Token 过期或无效ElMessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录','系统提示',{confirmButtonText: '重新登录',cancelButtonText: '取消',type: 'warning'}).then(() => {userStore.logout() // 调用 store 的 logout action 清除 token 并跳转登录页}).catch(() => {// 用户选择取消,可以什么都不做,或者提示用户某些功能可能不可用});} else if (status === 403) {message = '您没有权限执行此操作!'ElMessage({ message, type: 'error', duration: 5 * 1000 })} else if (status === 404) {message = '请求的资源未找到!'ElMessage({ message, type: 'error', duration: 5 * 1000 })} else if (status >= 500) {message = '服务器内部错误,请稍后再试!'ElMessage({ message, type: 'error', duration: 5 * 1000 })} else {ElMessage({ message, type: 'error', duration: 5 * 1000 })}} else if (error.message.includes('timeout')) {ElMessage({ message: '请求超时,请检查网络连接!', type: 'error', duration: 5 * 1000 })} else {ElMessage({ message: '请求失败,请检查网络或联系管理员!', type: 'error', duration: 5 * 1000 })}return Promise.reject(error)} )// 4. 导出封装好的 Axios 实例 export default service
代码解释:
import axios, { ... } from 'axios'
: 导入 Axios 及其相关的类型定义,这对于 TypeScript 项目非常重要。axios.create({ ... })
: 创建一个 Axios 实例service
。baseURL
: 设置 API 请求的基础 URL。-
import.meta.env.VITE_API_BASE_URL
: 我们尝试从 Vite 的环境变量中读取VITE_API_BASE_URL
。 -
|| '/api'
: 如果环境变量未设置,则默认为/api
。 -
配置
VITE_API_BASE_URL
:
在前端项目根目录 (test-platform/frontend
) 下创建.env.development
文件 (用于开发环境) 和.env.production
文件 (用于生产环境)。
# .env.development VITE_APP_TITLE=测试平台 (开发环境) # API 基础路径 (用于开发时直接请求后端服务) VITE_API_BASE_URL=http://127.0.0.1:8000/api
# .env.production VITE_APP_TITLE=测试平台 # API 基础路径 (用于生产环境,通常通过 Nginx 代理到 /api) VITE_API_BASE_URL=/api
这样,在开发时,
baseURL
会是http://127.0.0.1:8000/api
,可以直接跨域请求本地 Django 服务。在生产打包时,baseURL
会是/api
,通常会配置 Nginx 将/api
路径代理到后端服务。
-
timeout
: 设置请求超时时间。headers
: 设置默认的请求头。
- 请求拦截器 (
service.interceptors.request.use
):- 在每个请求被发送之前执行。
const userStore = useUserStore()
: 获取 Pinia Store 实例。if (userStore.token)
: 如果用户已登录 (Store 中有 Token),则在请求头中添加Authorization
字段。config.headers.Authorization = \
Bearer ${userStore.token}`: **重要!** 这里的 Token 格式 (
Bearer前缀) 需要与你后端 Django REST Framework 配置的认证方式一致。如果你的 DRF 使用的是
rest_framework_simplejwt,那么默认的
JWTAuthentication就期望
Bearer格式。如果你的后端需要不同的格式 (例如直接是 Token 值,或者
Token `),请相应修改。
return config
: 必须返回修改后的config
对象,否则请求不会被发送。
- 响应拦截器 (
service.interceptors.response.use
):- 第一个函数处理 HTTP 状态码为 2xx 的成功响应。
- 我们暂时直接
return response
。在实际项目中,你可能需要根据后端返回的特定业务状态码 (如res.code
或res.status
) 来进一步判断成功或失败,并可能只返回response.data
。
- 我们暂时直接
- 第二个函数处理 HTTP 状态码超出 2xx 范围的错误响应。
console.error(...)
: 打印错误信息到控制台,方便调试。if (error.response)
: 判断是 HTTP 错误 (有响应对象但状态码非 2xx)。const { status, data } = error.response
: 获取错误状态码和响应数据。- 根据不同的
status
(如 401, 403, 404, 500) 给出不同的用户提示 (使用ElMessage
或ElMessageBox
)。 - 特别地,对于
status === 401
(未授权),我们弹出一个确认框,如果用户选择“重新登录”,则调用userStore.logout()
来清除本地状态并跳转到登录页。
else if (error.message.includes('timeout'))
: 处理请求超时错误。else
: 处理其他网络错误 (如 DNS 解析失败、网络中断等)。return Promise.reject(error)
: 必须返回一个 rejected Promise,这样调用 API 的地方的.catch()
块才能捕获到错误。
- 第一个函数处理 HTTP 状态码为 2xx 的成功响应。
export default service
: 导出配置好的 Axios 实例,以便在其他地方使用。
关于跨域问题 (CORS) 在开发环境:
由于我们的前端开发服务器 (如http://localhost:5173
) 和后端 API 服务器 (http://127.0.0.1:8000
) 在不同的源 (协议、域名、端口有一个不同即为不同源),直接在前端 JS 中请求后端 API 会遇到浏览器的同源策略限制,导致跨域错误。有两种常见的解决方法,你只需要选择其中一种解决方法就可以了:
-
后端配置 CORS (Cross-Origin Resource Sharing): 在 Django 后端安装并配置
django-cors-headers
库,允许来自前端开发服务器源的请求。这是更规范的做法。-
pip install django-cors-headers -i https://pypi.tuna.tsinghua.edu.cn/simple
-
在
settings.py
中,拷贝以下内容进行替换:""" Django settings for backend project.Generated by 'django-admin startproject' using Django 5.2.1.For more information on this file, see https://docs.djangoproject.com/en/5.2/topics/settings/For the full list of settings and their values, see https://docs.djangoproject.com/en/5.2/ref/settings/ """from pathlib import Path# Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/# SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "django-insecure-n797j*a(g^*i_u^ibiwu+ia)oj7bd&t=$es(j1!h1hg71s&_q)"# SECURITY WARNING: don't run with debug turned on in production! DEBUG = TrueALLOWED_HOSTS = ['*'] # 允许所有主机# Application definitionINSTALLED_APPS = ["corsheaders","django.contrib.admin","django.contrib.auth","django.contrib.contenttypes","django.contrib.sessions","django.contrib.messages","django.contrib.staticfiles",'rest_framework', # 我们之前安装了DRF,在这里也注册上'api', # 添加我们新建的 app ]MIDDLEWARE = ["backend.cors_middleware.CustomCorsMiddleware", # 添加自定义中间件在最前面"corsheaders.middleware.CorsMiddleware", # django-cors-headers中间件"django.middleware.security.SecurityMiddleware","django.contrib.sessions.middleware.SessionMiddleware","django.middleware.common.CommonMiddleware","django.middleware.csrf.CsrfViewMiddleware","django.contrib.auth.middleware.AuthenticationMiddleware","django.contrib.messages.middleware.MessageMiddleware","django.middleware.clickjacking.XFrameOptionsMiddleware", ]# CORS 配置 - 完全放开 CORS_ORIGIN_ALLOW_ALL = True # 允许所有源 CORS_ALLOW_CREDENTIALS = True # 允许携带Cookie CORS_ALLOW_ALL_HEADERS = True # 允许所有头 CORS_ALLOW_METHODS = ["DELETE","GET","OPTIONS","PATCH","POST","PUT", ]ROOT_URLCONF = "backend.urls"TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates","DIRS": [],"APP_DIRS": True,"OPTIONS": {"context_processors": ["django.template.context_processors.request","django.contrib.auth.context_processors.auth","django.contrib.messages.context_processors.messages",],},}, ]WSGI_APPLICATION = "backend.wsgi.application"# https://docs.djangoproject.com/en/5.2/ref/settings/#databasesDATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3","NAME": BASE_DIR / "db.sqlite3",} }# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validatorsAUTH_PASSWORD_VALIDATORS = [{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",},{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",},{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",},{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",}, ]# Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/LANGUAGE_CODE = "en-us"TIME_ZONE = "UTC"USE_I18N = TrueUSE_TZ = True# Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.2/howto/static-files/STATIC_URL = "static/"# Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-fieldDEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
-
新增
cors_middleware.py
文件,拷贝以下内容:class CustomCorsMiddleware:def __init__(self, get_response):self.get_response = get_responsedef __call__(self, request):response = self.get_response(request)response["Access-Control-Allow-Origin"] = "http://127.0.0.1:5173"response["Access-Control-Allow-Headers"] = "*"response["Access-Control-Allow-Methods"] = "*"response["Access-Control-Allow-Credentials"] = "true"# 处理预检请求if request.method == "OPTIONS":response["Access-Control-Max-Age"] = "1000"response.status_code = 200return response
-
-
前端配置代理 (Vite Proxy): 在 Vite 的配置文件 (
vite.config.ts
) 中设置一个代理,将前端特定路径的 API 请求转发到后端服务器。这样浏览器看到的请求是发往同源的 (前端服务器),然后由前端服务器代为请求后端。// test-platform/frontend/vite.config.ts import { fileURLToPath, URL } from 'node:url' import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue'export default defineConfig(({ mode }) => {// 根据当前工作目录中的 `mode` 加载 .env 文件// 设置第三个参数为 '' 来加载所有环境变量,而不管是否有 `VITE_` 前缀。const env = loadEnv(mode, process.cwd(), '')return {plugins: [vue()],resolve: {alias: {'@': fileURLToPath(new URL('./src', import.meta.url))}},server: {host: '0.0.0.0', // 允许通过 IP 访问port: parseInt(env.VITE_PORT) || 5173, // 从 .env 读取端口号proxy: {// 字符串简写写法// '/foo': 'http://localhost:4567',// 带选项写法'/api': { // 当请求路径以 /api 开头时,会走这个代理target: 'http://127.0.0.1:8000', // 后端 API 服务器地址changeOrigin: true, // 需要虚拟主机站点rewrite: (path) => path.replace(/^\/api/, '/api') // 如果后端 API 本身就带 /api 前缀,这里可以不重写或重写为空 ''// 如果 VITE_API_BASE_URL 设置为 /api, 后端接口如 /api/projects// 我们的 baseURL 已设置为 'http://127.0.0.1:8000/api' (开发时) 或 '/api' (生产时)// 所以如果 baseURL 是 /api, 实际请求会是 /api/projects,// 而如果 target 是 http://127.0.0.1:8000, 那么我们可能需要将 /api/projects 重写为 /api/projects (如果后端本身路径是 /api/projects)// 或者如果后端本身路径是 /projects (不带 /api),则 rewrite: (path) => path.replace(/^\/api/, '')}}}} })
如果你的
VITE_API_BASE_URL
在开发时已经设置为了http://127.0.0.1:8000/api
,那么请求会直接发往后端,此时你必须在后端配置 CORS。
如果你的VITE_API_BASE_URL
在开发时设置为了/api
(与生产环境一致),那么你就需要在 Vite 中配置代理,将/api
的请求代理到http://127.0.0.1:8000
,并且rewrite
规则可能需要调整,确保最终到达后端的路径是正确的。推荐方案:开发时
VITE_API_BASE_URL
指向完整后端地址,并在后端配置 CORS。这样更接近真实部署情况。
第三步:创建 API 服务模块
为了让 API 调用更有组织性,我们可以为每个后端资源 (如项目、模块、用例) 创建一个对应的 API 服务文件。
-
创建
api
目录和文件:
在src
目录下创建一个api
文件夹,并在其中为project
创建一个project.ts
文件。
-
编写
project.ts
API 服务:
// test-platform/frontend/src/api/project.ts import request from '@/utils/request' // 导入我们封装的 Axios 实例 import type { AxiosPromise } from 'axios' // 导入 AxiosPromise 类型// 定义项目相关的类型 (可以从后端 API 文档或实际响应中获取) // 这些类型最好与后端 DRF Serializer 的输出字段对应 export interface Project {id: number;name: string;description: string | null;owner: string | null;status: number; // 0:规划中, 1:进行中, 2:已完成, 3:搁置// modules: any[]; // 如果需要嵌套显示模块create_time: string;update_time: string; }// 定义项目列表的响应类型 (如果后端有分页,结构会更复杂) export type ProjectListResponse = Project[] // 假设直接返回项目数组// 定义创建项目时发送的数据类型 export interface CreateProjectData {name: string;description?: string;owner?: string;status?: number; }// 1. 获取项目列表的 API export function getProjectList(params?: any): AxiosPromise<ProjectListResponse> {// params 可以用来传递查询参数,例如分页、筛选等return request({url: '/projects/', // 完整的 URL 会是 baseURL + /projects/method: 'get',params // GET 请求的参数放在 params 中}) }// 2. 创建项目的 API export function createProject(data: CreateProjectData): AxiosPromise<Project> {return request({url: '/projects/',method: 'post',data // POST/PUT/PATCH 请求的数据放在 data 中}) }// 3. 获取单个项目详情的 API export function getProjectDetail(projectId: number): AxiosPromise<Project> {return request({url: `/projects/${projectId}/`,method: 'get'}) }// 4. 更新项目的 API export function updateProject(projectId: number, data: Partial<CreateProjectData>): AxiosPromise<Project> {// Partial<CreateProjectData> 表示 data 对象中的属性都是可选的 (用于 PATCH)// 如果是 PUT (全量更新),类型可以是 CreateProjectDatareturn request({url: `/projects/${projectId}/`,method: 'put', // 或者 'patch'data}) }// 5. 删除项目的 API export function deleteProject(projectId: number): AxiosPromise<void> { // 删除通常没有响应体内容,所以 Promise<void>return request({url: `/projects/${projectId}/`,method: 'delete'}) }
代码解释:
import request from '@/utils/request'
: 导入我们之前封装的 Axios 实例。export interface Project { ... }
: 定义了Project
对象的 TypeScript 接口,这有助于类型检查和代码提示。这些字段应该与你后端 DRFProjectSerializer
输出的字段一致。export function getProjectList(...) { ... }
: 每个函数对应一个 API 端点。url
: API 的相对路径 (相对于baseURL
)。method
: HTTP 请求方法。params
: 用于 GET 请求的 URL 查询参数。data
: 用于 POST, PUT, PATCH 请求的请求体数据。AxiosPromise<ProjectListResponse>
: 指定了函数返回的是一个 AxiosPromise,并且其data
部分的类型是ProjectListResponse
。
第四步:在组件中调用 API
现在我们可以在组件中使用这些封装好的 API 函数了。以 ProjectListView.vue
为例,获取并显示项目列表。
<!-- test-platform/frontend/src/views/project/ProjectListView.vue -->
<template><div class="project-list-view"><div class="page-header"><h2>项目列表</h2><el-button type="primary" @click="handleCreateProject"><el-icon><Plus /></el-icon> 新建项目</el-button></div><el-table :data="projects" v-loading="loading" style="width: 100%" empty-text="暂无项目数据"><el-table-column prop="id" label="ID" width="80" /><el-table-column prop="name" label="项目名称" min-width="180" /><el-table-column prop="description" label="描述" min-width="250" show-overflow-tooltip /><el-table-column prop="owner" label="负责人" width="120" /><el-table-column prop="status" label="状态" width="120"><template #default="scope"><el-tag :type="getStatusTagType(scope.row.status)">{{ getStatusText(scope.row.status) }}</el-tag></template></el-table-column><el-table-column prop="create_time" label="创建时间" width="180"><template #default="scope">{{ formatDateTime(scope.row.create_time) }}</template></el-table-column><el-table-column label="操作" width="200" fixed="right"><template #default="scope"><el-button size="small" type="primary" @click="handleViewDetail(scope.row.id)">查看</el-button><el-button size="small" type="warning" @click="handleEditProject(scope.row)">编辑</el-button><el-popconfirmtitle="确定要删除这个项目吗?"confirm-button-text="确定"cancel-button-text="取消"@confirm="handleDeleteProject(scope.row.id)"><template #reference><el-button size="small" type="danger">删除</el-button></template></el-popconfirm></template></el-table-column></el-table><!-- 新建/编辑项目对话框 (后续添加) --><!-- <project-form-dialog v-model="dialogVisible" :project-id="editingProjectId" @success="fetchProjectList" /> --></div>
</template><script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' // 导入图标
import { getProjectList, deleteProject, type Project } from '@/api/project' // 1. 导入 API 函数和类型// import ProjectFormDialog from './components/ProjectFormDialog.vue'; // 假设有表单对话框组件const router = useRouter()
const projects = ref<Project[]>([]) // 2. 定义响应式数据来存储项目列表
const loading = ref(false)
// const dialogVisible = ref(false)
// const editingProjectId = ref<number | null>(null)// 3. 获取项目列表的函数
const fetchProjectList = async () => {loading.value = truetry {const response = await getProjectList() // 调用 API// 注意:如果你的 request.ts 中的响应拦截器直接返回 response.data (例如 return res)// 那么这里直接用 response (例如 projects.value = response)// 如果响应拦截器返回的是整个 AxiosResponse (例如 return response),那么需要取 response.dataprojects.value = response.data // 假设 getProjectList 返回 { data: Project[] } 结构console.log('Fetched projects:', projects.value)} catch (error) {console.error('获取项目列表失败:', error)// ElMessage.error('获取项目列表失败,请稍后再试') // 错误提示已在 request.ts 中统一处理} finally {loading.value = false}
}// 4. 在组件挂载时调用获取列表函数
onMounted(() => {fetchProjectList()
})// 辅助函数:格式化日期时间
const formatDateTime = (dateTimeStr: string) => {if (!dateTimeStr) return ''const date = new Date(dateTimeStr)return date.toLocaleString() // 或者使用更专业的日期格式化库如 dayjs
}// 辅助函数:获取状态文本
const getStatusText = (status: number) => {const statusMap: { [key: number]: string } = {0: '规划中',1: '进行中',2: '已完成',3: '搁置',}return statusMap[status] || '未知状态'
}// 辅助函数:获取状态标签类型
const getStatusTagType = (status: number) => {const typeMap: { [key: number]: '' | 'success' | 'warning' | 'info' | 'danger' } = {0: 'info',1: '', // 默认 (primary)2: 'success',3: 'warning',}return typeMap[status] || 'info'
}const handleCreateProject = () => {// editingProjectId.value = null;// dialogVisible.value = true;router.push('/project/create') // 跳转到创建页面ElMessage.info('跳转到新建项目页面 (功能待实现)')
}const handleViewDetail = (projectId: number) => {router.push(`/project/detail/${projectId}`)
}const handleEditProject = (project: Project) => {// editingProjectId.value = project.id;// dialogVisible.value = true;ElMessage.info(`编辑项目 ID: ${project.id} (功能待实现)`)
}const handleDeleteProject = async (projectId: number) => {loading.value = true // 可以加一个局部的 loading 状态或使用表格的 loadingtry {await deleteProject(projectId) // 调用删除 APIElMessage.success('项目删除成功!')fetchProjectList() // 重新加载列表} catch (error) {console.error('删除项目失败:', error)// ElMessage.error('删除项目失败') // 错误提示已在 request.ts 中统一处理} finally {loading.value = false}
}
</script><style scoped lang="scss">
.project-list-view {padding: 20px;.page-header {display: flex;justify-content: space-between;align-items: center;margin-bottom: 20px;}
}
</style>
代码解释:
import { getProjectList, deleteProject, type Project } from '@/api/project'
: 导入我们定义的 API 函数和Project
类型。const projects = ref<Project[]>([])
: 定义一个响应式引用来存储从后端获取的项目列表数据。const loading = ref(false)
: 用于控制表格的加载状态。fetchProjectList
:- 这是一个异步函数,用于调用
getProjectList()
API。 loading.value = true
开始加载。const response = await getProjectList()
: 使用await
等待 API 请求完成。projects.value = response.data
: 将 API 返回的数据 (假设在response.data
中) 赋值给projects
。这里需要注意,如果你的request.ts
的响应拦截器修改了返回结构 (例如直接返回res.data
而不是整个AxiosResponse
),那么这里的赋值方式需要相应调整。 我们的request.ts
示例是return response
,所以这里用response.data
是正确的。- 错误处理:
try...catch
块用于捕获 API 请求可能发生的错误。我们的request.ts
中的响应拦截器已经做了全局的错误提示,所以组件层面可以只console.error
,或者根据需要做更细致的局部处理。 loading.value = false
结束加载。
- 这是一个异步函数,用于调用
onMounted(() => { fetchProjectList() })
: 在组件挂载后(即 DOM 渲染完成后)立即调用fetchProjectList
来获取初始数据。- 表格中使用了
#default="scope"
的作用域插槽来定制单元格内容,例如状态的显示和操作按钮。 handleDeleteProject
: 演示了如何调用删除 API,并在成功后重新获取列表。
第五步:测试前后端联调
- 确保后端 Django 服务运行在
http://127.0.0.1:8000
。 - 确保后端 API
/api/projects/
可以正常返回项目列表数据 (可以通过 Postman 或浏览器直接访问http://127.0.0.1:8000/api/projects/
来测试)。 - 确保你的前端开发环境
VITE_API_BASE_URL
设置正确 (例如http://127.0.0.1:8000/api
) 并且后端 CORS 配置允许来自前端源的请求 (例如http://localhost:5173
)。 - 启动前端开发服务器:
npm run dev
- 登录并访问项目列表页 (
http://localhost:5173/project/list
):- 你应该能看到表格显示了从后端 API 获取到的真实项目数据。
- 表格应该有加载状态的指示。
- 打开浏览器开发者工具的 “Network” (网络) 标签页,你应该能看到向
/projects/
端点发起的 GET 请求,以及成功的响应 (状态码 200)。 - 检查请求头,确保
Authorization
头部被正确添加(如果已登录)。 - 尝试删除一个项目,观察网络请求和界面的变化。
常见问题与调试:
- CORS 错误: 如果在 Network 标签页看到 CORS 相关的错误,请检查你的 Django 后端
django-cors-headers
配置是否正确,CORS_ALLOWED_ORIGINS
是否包含了你的前端开发服务器地址。 - 401 未授权错误:
- 确保你已登录,并且
userStore
中的token
被正确设置。 - 检查
request.ts
中Authorization
请求头的格式是否与后端期望的一致 (例如Bearer <token>
)。 - 如果 Token 过期,响应拦截器中的 401 处理逻辑应该会被触发。
- 确保你已登录,并且
- 404 Not Found 错误: 检查
baseURL
和 API 的url
拼接后的完整路径是否正确,与后端 API 端点是否匹配。 - 500 服务器内部错误: 这通常是后端代码的问题,需要查看 Django 后端的日志来定位。
- 数据未显示或格式不正确:
- 在
fetchProjectList
中console.log(response)
打印完整的响应对象,查看数据结构是否与预期一致。 - 检查
projects.value = response.data
是否正确地取到了数据数组。 - 检查
Project
类型定义是否与后端返回的字段匹配。
- 在
总结
在这篇文章中,我们成功地打通了 Vue3 前端和 Django 后端之间的数据交互通道:
- ✅ 安装了 Axios HTTP 客户端库。
- ✅ 创建并封装了一个 Axios 实例 (
utils/request.ts
),配置了:- 基础 URL (
baseURL
),并利用 Vite 环境变量使其在开发和生产环境可配置。 - 请求超时时间。
- 请求拦截器: 统一为需要认证的请求添加
Authorization
Token 头部。 - 响应拦截器: 统一处理 HTTP 成功响应和错误响应,包括对常见错误状态码 (如 401, 403, 404, 500) 的全局提示和处理逻辑 (如 401 时自动登出)。
- 基础 URL (
- ✅ 讨论了开发环境中的跨域问题 (CORS) 及其解决方案 (后端配置 CORS 或前端配置代理),并推荐了后端 CORS 方案。
- ✅ 创建了模块化的 API 服务文件 (
api/project.ts
),用于集中管理与特定资源相关的 API 调用函数,并定义了相关的 TypeScript 类型。 - ✅ 在 Vue 组件 (
ProjectListView.vue
) 中调用了封装好的 API 函数来获取项目列表数据,并将其展示在 Element Plus 表格中。 - ✅ 演示了如何在组件中处理加载状态和调用删除 API。
- ✅ 指导了如何测试前后端联调的效果并分析常见问题。
现在,你的前端应用已经具备了与后端 API 进行真实数据交互的能力!这是构建一个功能完整的全栈测试平台的关键一步。
在接下来的文章中,我们将基于这个联调基础,逐步实现项目中其他核心功能模块的前端页面和逻辑,例如创建/编辑项目表单、模块管理、测试用例管理等,让我们的测试平台越来越完善。