九、【前后端联调篇】Vue3 + Axios 异步通信实战
前言
在 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 ' + token config.headers.Authorization = `Bearer ${userStore.token}` } console.log('Request config:', config) // 调试用 return config }, (error) => { // 对请求错误做些什么 console.error('Request Error:', error) // for debug return Promise.reject(error) } ) // 3. 响应拦截器 (Response Interceptor) service.interceptors.response.use( (response: AxiosResponse) => { // 对响应数据做点什么 // HTTP 状态码为 2xx 时会进入这里 const res = response.data console.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 debug const userStore = useUserStore() // 在错误处理中也可能需要访问 store if (error.response) { const { status, data } = error.response let 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 = True ALLOWED_HOSTS = ['*'] # 允许所有主机 # Application definition INSTALLED_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/#databases DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3", } } # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators AUTH_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 = True USE_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-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
新增
cors_middleware.py
文件,拷贝以下内容:class CustomCorsMiddleware: def __init__(self, get_response): self.get_response = get_response def __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 = 200 return 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 (全量更新),类型可以是 CreateProjectData return 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-popconfirm
title="确定要删除这个项目吗?"
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 = true
try {
const response = await getProjectList() // 调用 API
// 注意:如果你的 request.ts 中的响应拦截器直接返回 response.data (例如 return res)
// 那么这里直接用 response (例如 projects.value = response)
// 如果响应拦截器返回的是整个 AxiosResponse (例如 return response),那么需要取 response.data
projects.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 状态或使用表格的 loading
try {
await deleteProject(projectId) // 调用删除 API
ElMessage.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 进行真实数据交互的能力!这是构建一个功能完整的全栈测试平台的关键一步。
在接下来的文章中,我们将基于这个联调基础,逐步实现项目中其他核心功能模块的前端页面和逻辑,例如创建/编辑项目表单、模块管理、测试用例管理等,让我们的测试平台越来越完善。