九、【前后端联调篇】Vue3 + Axios 异步通信实战

发布于:2025-05-29 ⋅ 阅读:(29) ⋅ 点赞:(0)

前言

在 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: 增强安全性。
  • 浏览器兼容性好。

准备工作

  1. 前端项目已就绪: test-platform/frontend 项目可以正常运行 (npm run dev)。
    在这里插入图片描述

  2. 后端 API 运行: 确保你的 Django 后端开发服务器 (python manage.py runserver) 可运行,并且 API (http://127.0.0.1:8000/api/...) 可以正常访问。
    在这里插入图片描述
    在这里插入图片描述

  3. Pinia 用户状态管理已配置: 我们将使用 userStore 中的 Token 来演示如何在请求头中添加认证信息。

第一步:安装 Axios

如果你的项目中还没有 Axios,首先需要安装它。

在前端项目根目录 (test-platform/frontend) 下打开终端,运行:

npm install axios --save

在这里插入图片描述

这会将 Axios 添加到你的项目依赖中。

第二步:封装 Axios 实例

为了更好地管理 API 请求,通常我们会创建一个 Axios 实例,并对其进行一些全局配置,而不是在每个组件中都直接使用 axios.get(...)

  1. 创建 utils/request.ts 文件:
    src 目录下创建一个 utils 文件夹,并在其中创建一个 request.ts 文件。
    在这里插入图片描述

  2. 编写 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.coderes.status) 来进一步判断成功或失败,并可能只返回 response.data
      • 第二个函数处理 HTTP 状态码超出 2xx 范围的错误响应。
        • console.error(...): 打印错误信息到控制台,方便调试。
        • if (error.response): 判断是 HTTP 错误 (有响应对象但状态码非 2xx)。
          • const { status, data } = error.response: 获取错误状态码和响应数据。
          • 根据不同的 status (如 401, 403, 404, 500) 给出不同的用户提示 (使用 ElMessageElMessageBox)。
          • 特别地,对于 status === 401 (未授权),我们弹出一个确认框,如果用户选择“重新登录”,则调用 userStore.logout() 来清除本地状态并跳转到登录页。
        • else if (error.message.includes('timeout')): 处理请求超时错误。
        • else: 处理其他网络错误 (如 DNS 解析失败、网络中断等)。
        • return Promise.reject(error): 必须返回一个 rejected Promise,这样调用 API 的地方的 .catch() 块才能捕获到错误。
    • export default service: 导出配置好的 Axios 实例,以便在其他地方使用。

    关于跨域问题 (CORS) 在开发环境:
    由于我们的前端开发服务器 (如 http://localhost:5173) 和后端 API 服务器 (http://127.0.0.1:8000) 在不同的源 (协议、域名、端口有一个不同即为不同源),直接在前端 JS 中请求后端 API 会遇到浏览器的同源策略限制,导致跨域错误。

    有两种常见的解决方法,你只需要选择其中一种解决方法就可以了:

    1. 后端配置 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
        
    2. 前端配置代理 (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 服务文件。

  1. 创建 api 目录和文件:
    src 目录下创建一个 api 文件夹,并在其中为 project 创建一个 project.ts 文件。
    在这里插入图片描述

  2. 编写 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 接口,这有助于类型检查和代码提示。这些字段应该与你后端 DRF ProjectSerializer 输出的字段一致。
    • 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,并在成功后重新获取列表。

第五步:测试前后端联调

  1. 确保后端 Django 服务运行在 http://127.0.0.1:8000
  2. 确保后端 API /api/projects/ 可以正常返回项目列表数据 (可以通过 Postman 或浏览器直接访问 http://127.0.0.1:8000/api/projects/ 来测试)。
  3. 确保你的前端开发环境 VITE_API_BASE_URL 设置正确 (例如 http://127.0.0.1:8000/api) 并且后端 CORS 配置允许来自前端源的请求 (例如 http://localhost:5173)。
  4. 启动前端开发服务器:
    npm run dev
    
  5. 登录并访问项目列表页 (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.tsAuthorization 请求头的格式是否与后端期望的一致 (例如 Bearer <token>)。
    • 如果 Token 过期,响应拦截器中的 401 处理逻辑应该会被触发。
  • 404 Not Found 错误: 检查 baseURL 和 API 的 url 拼接后的完整路径是否正确,与后端 API 端点是否匹配。
  • 500 服务器内部错误: 这通常是后端代码的问题,需要查看 Django 后端的日志来定位。
  • 数据未显示或格式不正确:
    • fetchProjectListconsole.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 时自动登出)。
  • 讨论了开发环境中的跨域问题 (CORS) 及其解决方案 (后端配置 CORS 或前端配置代理),并推荐了后端 CORS 方案。
  • 创建了模块化的 API 服务文件 (api/project.ts),用于集中管理与特定资源相关的 API 调用函数,并定义了相关的 TypeScript 类型。
  • 在 Vue 组件 (ProjectListView.vue) 中调用了封装好的 API 函数来获取项目列表数据,并将其展示在 Element Plus 表格中。
  • 演示了如何在组件中处理加载状态和调用删除 API。
  • 指导了如何测试前后端联调的效果并分析常见问题。

现在,你的前端应用已经具备了与后端 API 进行真实数据交互的能力!这是构建一个功能完整的全栈测试平台的关键一步。

在接下来的文章中,我们将基于这个联调基础,逐步实现项目中其他核心功能模块的前端页面和逻辑,例如创建/编辑项目表单、模块管理、测试用例管理等,让我们的测试平台越来越完善。


网站公告

今日签到

点亮在社区的每一天
去签到