当前位置: 首页 > news >正文

十二、【核心功能篇】测试用例列表与搜索:高效展示和查找海量用例

【核心功能篇】测试用例列表与搜索:高效展示和查找海量用例

    • 前言
      • 准备工作
      • 第一步:更新 API 服务以支持分页和更完善的搜索
      • 第二步:创建测试用例列表页面组件 (`src/views/testcase/TestCaseListView.vue`)
      • 第三步:测试列表、搜索、筛选和分页
    • 总结

前言

当测试用例数量逐渐增多,一个简单罗列所有用例的列表将变得非常低效和不友好。我们需要:

  • 清晰的列表展示: 以表格形式清晰展示用例的关键信息。
  • 分页加载: 避免一次性加载过多数据导致页面卡顿。
  • 关键字搜索: 能够根据用例名称、描述等关键词快速查找。
  • 条件筛选: 能够根据所属项目、模块、优先级、类型等条件进行筛选。
  • 便捷的操作: 在列表行内直接提供编辑、删除等快捷操作入口。

我们将创建一个 TestCaseListView.vue 组件,并利用 Element Plus 的 ElTableElPaginationElForm 组件来实现这些功能。

准备工作

  1. 前端项目就绪: test-platform/frontend 项目可以正常运行 (npm run dev)。
  2. 后端 API 运行中: Django 后端服务运行(python manage.py runserver
    ),测试用例的 API (/api/testcases/) 可用,并且支持通过查询参数进行过滤 (如 project_id, module_id, search 等) 和分页。
  3. Axios 和 API 服务已封装: utils/request.tsapi/testcase.ts (包含 getTestCaseList 函数) 已配置好。
  4. Element Plus 集成完毕。
  5. 测试用例编辑页面可用: 我们将从列表页跳转到编辑页。

第一步:更新 API 服务以支持分页和更完善的搜索

我们的后端 DRF TestCaseViewSet 默认就支持分页 (如果继承了 ModelViewSet 并配置了 pagination_class),并且通过 SearchFilterDjangoFilterBackend 可以实现搜索和过滤。我们需要确保前端的 API 服务函数能够传递这些参数,并正确处理分页响应。

1. 修改 frontend/src/api/testcase.ts 中的类型和函数:
DRF 分页响应通常包含 count, next, previous, results 字段。
在这里插入图片描述

// test-platform/frontend/src/api/testcase.ts
import request from '@/utils/request'
import type { AxiosPromise } from 'axios'// TestCase 接口定义保持不变...
export interface TestCase {id: number;name: string;description: string | null;module: number;module_name?: string;project_id?: number;project_name?: string;priority: 'P0' | 'P1' | 'P2' | 'P3';priority_display?: string;precondition: string | null;steps_text: string;expected_result: string;case_type: 'functional' | 'api' | 'ui';case_type_display?: string;maintainer: string | null;create_time: string;update_time: string;
}// 更新:定义分页响应的通用接口
export interface PaginatedResponse<T> {count: number;next: string | null;previous: string | null;results: T[];
}// 更新:TestCaseListResponse 现在使用 PaginatedResponse
export type TestCaseListResponse = PaginatedResponse<TestCase>// 定义获取测试用例列表的参数类型
export interface GetTestCaseListParams {page?: number;page_size?: number; // 后端 DRF 通常用 page_sizesearch?: string; // 关键词搜索project_id?: number | null;module_id?: number | null;priority?: string | null;case_type?: string | null;ordering?: string; // 排序字段,例如 '-create_time'
}// 1. 获取测试用例列表 (支持分页、搜索、过滤、排序)
export function getTestCaseList(params?: GetTestCaseListParams): AxiosPromise<TestCaseListResponse> {return request({url: '/testcases/',method: 'get',params // 将所有参数传递给后端})
}// createTestCase, getTestCaseDetail, updateTestCase, deleteTestCase 函数保持不变...
// ...

关键变更:

  • PaginatedResponse<T>: 定义了一个通用的分页响应接口。
  • TestCaseListResponse: 类型更新为 PaginatedResponse<TestCase>
  • GetTestCaseListParams: 定义了更详细的查询参数类型,包括 page, page_size, search 以及各种过滤条件。
  • getTestCaseList 函数现在接收 GetTestCaseListParams 类型的参数。

2. 确保后端 TestCaseViewSet 支持搜索和过滤:
在你的 Django 后端 api/views.py 中的 TestCaseViewSet,你需要确保配置了相应的 filter_backendssearch_fields / filterset_fields
在这里插入图片描述
在这里插入图片描述

# test-platform/api/views.py
from rest_framework import viewsets, filters # 确保导入 filters
from django_filters.rest_framework import DjangoFilterBackend # 确保导入 DjangoFilterBackend
# ... 其他导入 ...
from .models import TestCase
from .serializers import TestCaseSerializerclass TestCaseViewSet(viewsets.ModelViewSet):queryset = TestCase.objects.all().order_by('-update_time') # 默认排序serializer_class = TestCaseSerializerfilter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]# DjangoFilterBackend 的精确匹配字段filterset_fields = {'module__project_id': ['exact'], # /api/testcases/?module__project_id=1'module_id': ['exact'],         # /api/testcases/?module_id=5'priority': ['exact'],          # /api/testcases/?priority=P1'case_type': ['exact'],         # /api/testcases/?case_type=api}# SearchFilter 的模糊搜索字段search_fields = ['name', 'description', 'precondition', 'steps_text', 'expected_result','maintainer'] # /api/testcases/?search=登录# OrderingFilter 允许排序的字段ordering_fields = ['id', 'name', 'priority', 'case_type', 'create_time', 'update_time']# pagination_class = YourCustomPagination # 如果使用了自定义分页器

重要:

  • 要使用 DjangoFilterBackend,你需要先安装 django-filter 包。在你的 Django 项目的虚拟环境中运行:

    pip install django-filter
    

    在这里插入图片描述

  • INSTALLED_APPS 中注册 'django_filters'
    在这里插入图片描述

      # settings.pyINSTALLED_APPS = [# ... 其他应用 ...'rest_framework','django_filters', # 添加这一行'api',# ...]
    
  • filterset_fields 用于精确匹配,例如选择某个项目下的用例。我们这里用 module__project_id 来通过模块关联到项目进行筛选。

  • search_fields 用于关键词的模糊搜索。

  • ordering_fields 允许前端指定排序。

  • DRF 的默认分页器 (PageNumberPagination) 使用 pagepage_size (或 limit/offset 如果是 LimitOffsetPagination) 参数。确保前端传递的参数名与后端分页器配置一致。

第二步:创建测试用例列表页面组件 (src/views/testcase/TestCaseListView.vue)

这个组件将包含搜索/筛选表单、用例表格和分页控件。
在上一篇【核心功能】测试用例管理:设计强大的用例编辑界面,我们已经在frontend/src/router/index.ts中添加了用例列表的路由信息,如下所示:
在这里插入图片描述

// test-platform/frontend/src/router/index.ts
// ... (在 Layout 的 children 中添加){path: '/testcases', // 用例列表页name: 'testcases',component: () => import('../views/testcase/TestCaseListView.vue'), //之前的文件路径是'../views/project/TestCaseListView.vue',这个是不对的,所以这里进行了更正。meta: { title: '用例管理', requiresAuth: true }},
// ...

a. 创建文件:
src/views/testcase/TestCaseListView.vue 尚不存在,现在来创建它。

b. 编写 TestCaseListView.vue
在这里插入图片描述

<!-- test-platform/frontend/src/views/testcase/TestCaseListView.vue -->
<template><div class="testcase-list-view" v-loading="pageLoading"><el-card class="filter-card"><el-form :inline="true" :model="queryParams" ref="queryFormRef" @submit.prevent="handleSearch"><el-form-item label="所属项目" prop="project_id"><el-selectv-model="queryParams.project_id"placeholder="请选择项目"clearablestyle="width: 180px"@change="onProjectChange"@focus="fetchProjectsForSelect":loading="projectSelectLoading"><el-option v-for="item in projectOptions" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="所属模块" prop="module_id"><el-selectv-model="queryParams.module_id"placeholder="请选择模块"clearablefilterablestyle="width: 180px":disabled="!queryParams.project_id":loading="moduleSelectLoading"><el-option v-for="item in moduleOptions" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="优先级" prop="priority"><el-select v-model="queryParams.priority" placeholder="优先级" clearable style="width: 120px"><el-option label="P0" value="P0" /><el-option label="P1" value="P1" /><el-option label="P2" value="P2" /><el-option label="P3" value="P3" /></el-select></el-form-item><el-form-item label="类型" prop="case_type"><el-select v-model="queryParams.case_type" placeholder="用例类型" clearable style="width: 140px"><el-option label="功能测试" value="functional" /><el-option label="接口测试" value="api" /><el-option label="UI测试" value="ui" /></el-select></el-form-item><el-form-item label="关键词" prop="search"><el-input v-model="queryParams.search" placeholder="名称/描述/步骤等" clearable style="width: 200px" /></el-form-item><el-form-item><el-button type="primary" :icon="SearchIcon" @click="handleSearch">搜索</el-button><el-button :icon="RefreshIcon" @click="handleReset">重置</el-button></el-form-item></el-form></el-card><el-card class="table-card"><template #header><div class="card-header"><span>测试用例列表</span><el-button type="primary" :icon="PlusIcon" @click="navigateToCreate">新建用例</el-button></div></template><el-table :data="testCases" v-loading="tableLoading" style="width: 100%" empty-text="暂无测试用例数据"><el-table-column prop="id" label="ID" width="80" sortable /><el-table-column prop="name" label="用例名称" min-width="200" show-overflow-tooltip sortable><template #default="scope"><el-link type="primary" @click="handleEdit(scope.row.id)">{{ scope.row.name }}</el-link></template></el-table-column><el-table-column prop="project_name" label="所属项目" width="150" show-overflow-tooltip /><el-table-column prop="module_name" label="所属模块" width="150" show-overflow-tooltip /><el-table-column prop="priority_display" label="优先级" width="100" sortable><template #default="scope"><el-tag :type="getPriorityTagType(scope.row.priority)">{{ scope.row.priority_display || scope.row.priority }}</el-tag></template></el-table-column><el-table-column prop="case_type_display" label="类型" width="120" sortable><template #default="scope">{{ scope.row.case_type_display || getCaseTypeText(scope.row.case_type) }}</template></el-table-column><el-table-column prop="maintainer" label="维护人" width="120" show-overflow-tooltip /><el-table-column prop="update_time" label="最后更新" width="170" sortable><template #default="scope">{{ formatDateTime(scope.row.update_time) }}</template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="scope"><el-button size="small" type="warning" :icon="EditIcon" @click="handleEdit(scope.row.id)">编辑</el-button><el-popconfirmtitle="确定要删除这个用例吗?"@confirm="handleDelete(scope.row.id)"><template #reference><el-button size="small" type="danger" :icon="DeleteIcon">删除</el-button></template></el-popconfirm></template></el-table-column></el-table><el-paginationv-if="totalCases > 0"class="pagination-container":current-page="queryParams.page":page-size="queryParams.page_size":page-sizes="[10, 20, 50, 100]"layout="total, sizes, prev, pager, next, jumper":total="totalCases"@size-change="handleSizeChange"@current-change="handlePageChange"/></el-card></div>
</template><script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' // 导入 useRoute
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { Search as SearchIcon, Refresh as RefreshIcon, Plus as PlusIcon, Edit as EditIcon, Delete as DeleteIcon } from '@element-plus/icons-vue'
import { getTestCaseList, deleteTestCase, type TestCase, type GetTestCaseListParams } from '@/api/testcase'
import { getProjectList, type Project } from '@/api/project'
import { getModuleList as fetchModulesApi, type Module as ApiModule } from '@/api/module' // 避免与组件内变量冲突const router = useRouter()
const route = useRoute() // 获取当前路由信息const pageLoading = ref(false) // 页面级加载,例如初始加载项目模块选项
const tableLoading = ref(false)
const queryFormRef = ref<FormInstance>()const testCases = ref<TestCase[]>([])
const totalCases = ref(0)const queryParams = reactive<GetTestCaseListParams>({page: 1,page_size: 10,search: '',project_id: null,module_id: null,priority: null,case_type: null,ordering: '-update_time', // 默认按更新时间降序
})const projectOptions = ref<Project[]>([])
const moduleOptions = ref<ApiModule[]>([])
const projectSelectLoading = ref(false)
const moduleSelectLoading = ref(false)// 获取测试用例列表
const fetchTestCases = async () => {tableLoading.value = truetry {const response = await getTestCaseList(queryParams)console.log('API返回数据:', response.data) // 添加这行用于调试// 检查返回的数据结构if (response.data && Array.isArray(response.data.results)) {testCases.value = response.data.resultstotalCases.value = response.data.count} else if (response.data && Array.isArray(response.data.entities)) {// 如果API返回的是entities而不是resultstestCases.value = response.data.entitiestotalCases.value = response.data.total} else if (Array.isArray(response.data)) {// 如果API直接返回数组testCases.value = response.datatotalCases.value = response.data.length} else {console.error('API返回的数据结构不符合预期:', response.data)testCases.value = []totalCases.value = 0}} catch (error) {console.error('获取测试用例列表失败:', error)testCases.value = []totalCases.value = 0} finally {tableLoading.value = false}
}// 获取项目列表用于筛选器
const fetchProjectsForSelect = async () => {if (projectOptions.value.length > 0) return; // 避免重复加载projectSelectLoading.value = truetry {const response = await getProjectList({ page_size: 1000 }) // 获取足够多的项目,或实现分页/搜索选择器projectOptions.value = response.data // 假设不分页,或取第一页} catch (error) {console.error('获取项目选项失败:', error)} finally {projectSelectLoading.value = false}
}// 根据选中的项目获取模块列表用于筛选器
const fetchModulesForSelect = async (projectId: number | null) => {moduleOptions.value = [] // 清空旧模块queryParams.module_id = null // 清空已选模块if (!projectId) return;moduleSelectLoading.value = truetry {const response = await fetchModulesApi(projectId) // 调用 api/module.ts 中的 getModuleListmoduleOptions.value = response.data} catch (error) {console.error('获取模块选项失败:', error)} finally {moduleSelectLoading.value = false}
}// 监听项目选择变化,动态加载模块
watch(() => queryParams.project_id, (newProjectId) => {fetchModulesForSelect(newProjectId)
})// 页面加载时初始化
onMounted(async () => {pageLoading.value = true;await fetchProjectsForSelect(); // 先加载项目选项// 检查路由查询参数中是否有 moduleId (例如从用例编辑页跳转过来)const routeModuleId = route.query.moduleId as string | undefined;if (routeModuleId) {queryParams.module_id = Number(routeModuleId);// 如果有 moduleId,尝试找到其对应的 projectId 并设置const moduleDetail = moduleOptions.value.find(m => m.id === queryParams.module_id) ||(await fetchModulesApi().then(res => res.data.find(m => m.id === Number(routeModuleId)))); // 如果初始 moduleOptions 为空,则重新获取所有模块来查找if(moduleDetail && moduleDetail.project){queryParams.project_id = moduleDetail.project;await fetchModulesForSelect(queryParams.project_id); // 确保选中项目的模块列表被加载queryParams.module_id = Number(routeModuleId); // 再次设置,因为 fetchModulesForSelect 会清空它}}await fetchTestCases();pageLoading.value = false;
})const handleSearch = () => {queryParams.page = 1 // 搜索时重置到第一页fetchTestCases()
}const handleReset = () => {queryFormRef.value?.resetFields() // Element Plus 表单重置方法// queryParams.project_id = null; // resetFields可能不会重置非表单组件绑定的值// queryParams.module_id = null;// moduleOptions.value = [];// 手动重置非表单元素绑定的queryParams属性queryParams.page = 1;queryParams.project_id = null; // 这会触发 watch 清空 module_id 和 moduleOptionsqueryParams.priority = null;queryParams.case_type = null;queryParams.search = '';// 重置后立即搜索fetchTestCases()
}const handlePageChange = (newPage: number) => {queryParams.page = newPagefetchTestCases()
}const handleSizeChange = (newSize: number) => {queryParams.page_size = newSizequeryParams.page = 1 // 切换每页大小时回到第一页fetchTestCases()
}const navigateToCreate = () => {router.push('/testcase/create')
}const handleEdit = (testCaseId: number) => {router.push(`/testcase/edit/${testCaseId}`)
}const handleDelete = async (testCaseId: number) => {try {await ElMessageBox.confirm('此操作将永久删除该测试用例,是否继续?', '警告', {confirmButtonText: '确定删除',cancelButtonText: '取消',type: 'warning',});tableLoading.value = true; // 表示正在删除await deleteTestCase(testCaseId);ElMessage.success('测试用例删除成功!');// 如果删除的是当前页的最后一条数据,且不是第一页,则可能需要跳转到前一页if (testCases.value.length === 1 && queryParams.page! > 1) {queryParams.page!--;}fetchTestCases(); // 重新加载列表} catch (error) {if (error !== 'cancel') { // 用户点击取消时不提示错误console.error('删除测试用例失败:', error);}} finally {tableLoading.value = false;}
}// 辅助函数
const formatDateTime = (dateTimeStr: string) => {if (!dateTimeStr) return ''return new Date(dateTimeStr).toLocaleString()
}
const getPriorityTagType = (priority: string) => {const map: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = { P0: 'danger', P1: 'warning', P2: '', P3: 'info' }return map[priority] || 'info'
}
const getCaseTypeText = (caseType: string) => {const map: Record<string, string> = { functional: '功能测试', api: '接口测试', ui: 'UI测试' };return map[caseType] || caseType;
}</script><style scoped lang="scss">
.testcase-list-view {padding: 20px;.filter-card {margin-bottom: 20px;}.table-card {.card-header {display: flex;justify-content: space-between;align-items: center;}}.pagination-container {margin-top: 20px;display: flex;justify-content: flex-end;}
}
</style>

代码解释与关键点:

  • 筛选表单 (filter-card):
    • 使用 el-formel-form-item 构建筛选区域。
    • 项目选择器: fetchProjectsForSelect 获取项目列表填充选项。
    • 模块选择器: fetchModulesForSelect 根据选中的项目动态加载模块选项。通过 watch(() => queryParams.project_id, ...) 实现联动。
    • 其他筛选条件如优先级、类型、关键词输入框。
    • “搜索”按钮调用 handleSearch,“重置”按钮调用 handleReset
  • 用例表格 (table-card):
    • 顶部有“新建用例”按钮。
    • 使用 el-table 展示用例数据。关键列包括 ID, 名称 (可点击跳转编辑), 所属项目/模块, 优先级 (带标签), 类型, 更新时间等。
    • show-overflow-tooltip 用于内容过长时显示省略号和tooltip。
    • “操作”列包含“编辑”和“删除”按钮。
  • 分页控件 (el-pagination):
    • totalCases > 0 时显示。
    • 绑定了 queryParams.page, queryParams.page_size, totalCases
    • 监听 @size-change@current-change 事件,分别调用 handleSizeChangehandlePageChange 来更新查询参数并重新获取数据。
  • 数据获取与状态:
    • testCases, totalCases, loading (分为 pageLoadingtableLoading) 等响应式变量。
    • queryParams 对象存储所有查询、筛选和分页参数。
    • fetchTestCases() 是核心数据获取函数,它将 queryParams 发送给 getTestCaseList API,并用返回的 resultscount 更新本地状态。
  • 初始化加载:
    • onMounted 中首先加载项目选项,然后检查路由查询参数 moduleId (用于支持从用例编辑页成功后跳转回列表并筛选到对应模块)。最后调用 fetchTestCases 加载初始用例列表。
  • 操作处理:
    • handleSearch: 重置页码为1,然后获取数据。
    • handleReset: 重置表单和 queryParams (注意 resetFields 对非表单组件绑定的数据可能无效,需要手动重置),然后获取数据。
    • navigateToCreate, handleEdit: 使用 router.push 跳转到用例创建/编辑页。
    • handleDelete: 弹出确认框,调用删除 API,成功后刷新列表。增加了删除当前页最后一条数据时页码处理的逻辑。
  • 辅助函数: 用于格式化日期、显示优先级标签颜色、用例类型文本等。

第三步:测试列表、搜索、筛选和分页

  1. 确保后端服务运行正常,并且你已经创建了一些测试用例,分布在不同的项目和模块下,具有不同的优先级和类型。

  2. 启动前端开发服务器 (npm run dev)。

  3. 登录并访问用例列表页 (http://127.0.0.1:5173/testcases):
    在这里插入图片描述

    • 初始加载: 表格应显示第一页的测试用例数据,分页控件应正确显示总数。
    • 分页测试: 点击页码、上一页/下一页、改变每页显示数量,观察表格数据和分页控件是否正确更新,Network 面板中的 API 请求参数 (page, page_size) 是否正确。
    • 筛选测试:
      • 选择一个项目,模块选择器应动态加载该项目下的模块。
      • 选择项目、模块、优先级、类型,点击“搜索”。观察表格数据是否按条件筛选,Network 请求参数是否正确。
    • 关键词搜索测试: 输入关键词 (如用例名称的一部分),点击“搜索”。观察结果。
    • 重置测试: 点击“重置”按钮,所有筛选条件应清空,列表应恢复显示所有用例的第一页。
    • 操作测试:
      • 点击“新建用例”按钮,应跳转到用例创建页。
      • 点击某行用例的名称或“编辑”按钮,应跳转到该用例的编辑页,并能正确加载数据。
      • 点击“删除”按钮,确认后,该用例应被删除,列表刷新。
    • 从编辑/新建页跳转测试: 在用例编辑/新建成功后,会跳转回列表页,并尝试根据创建/编辑的用例所属模块进行筛选。验证此功能。

总结

在这篇文章中,我们成功地实现了测试用例的列表展示、分页、搜索和筛选:

  • 更新了 api/testcase.ts 中的 API 服务函数和类型定义,以更好地支持分页、搜索和过滤参数,并处理 DRF 标准的分页响应结构。
  • 确保了后端 TestCaseViewSet 配置了 SearchFilterDjangoFilterBackend 以支持前端的搜索和筛选需求。
  • 创建并实现了 TestCaseListView.vue 组件,其核心功能包括:
    • 一个包含项目、模块、优先级、类型选择器以及关键词输入框的筛选/搜索表单
    • 项目和模块选择器的联动加载
    • 使用 ElTable 清晰地展示测试用例的关键信息,并提供行内操作。
    • 使用 ElPagination 实现前端分页控制,与后端分页数据同步。
  • 实现了从列表页到用例创建/编辑页的跳转,以及从创建/编辑成功后返回列表并尝试定位到相关模块的逻辑。

现在,我们的测试平台在用例管理方面已经具备了查找、展示、新建、编辑、删除的完整闭环,可以高效地管理和维护测试用例库了。

在下一篇文章中,我们将继续完善核心功能,开始构建测试计划/测试套件的管理。这将涉及到如何从现有的用例库中选择用例,将它们组织成可执行的集合。

相关文章:

  • Day 34 训练
  • Sublime Text 4格式化JSON无效的解决方法
  • vscode命令行debug
  • NIO知识点
  • 电路笔记(通信):CAN 仲裁机制(Arbitration Mechanism) 位级监视线与特性先占先得非破坏性仲裁
  • 回车键为什么叫做“回车键”?
  • Spring Boot 应用中实现配置文件敏感信息加密解密方案
  • LINUX530 rsync定时同步 环境配置
  • 量化qmt跟单聚宽小市值策略开发成功
  • [春秋云镜] CVE-2023-23752 writeup
  • 前端面试准备-3
  • Agent + MCP工具实现数据库查询
  • 深度剖析Node.js的原理及事件方式
  • day14 leetcode-hot100-25(链表4)
  • 动态规划之网格图模型(一)
  • 单元测试报错
  • 【ClickHouse】RollingBitmap
  • [3D GISMesh]三角网格模型中的孔洞修补算法
  • Ubuntu 18.04 上源码安装 protobuf 3.7.0
  • java/mysql/ES下的日期类型分析
  • 姓氏头像在线制作免费生成图片/百度seo优化及推广
  • 有做全棉坯布的网站吗/网盘搜索引擎入口
  • 万网网站建设方案书/济宁网站建设
  • 深圳做网站要/网络推广理实一体化软件
  • 新手怎么做自己网站广告/关键词有哪些?
  • 海南在线人才网招聘官网/赣州seo外包