# 使用 Vue 3 + Element Plus 从头开始写一个数据库网站 - 06 - 前端与后端的交互

由于 PrismJS 尚不支持 Vue 的语法高亮,因此 Vue 代码块均先使用 HTML 的高亮

2024-09-23

在上一步中,我们已经设计并实现了基础的 API 功能。

现在我们在前端使用 Vue 发送请求并展示数据,分为以下几个步骤:

  1. 安装 Axios 用于发送 HTTP 请求
  2. 发送请求获取数据
  3. 展示 Biomarker 列表
  4. 根据条件筛选 Biomarker
  5. 点击 Biomarker 显示详细信息

我们仍然通过 Vue 3 的组合式 API 写法来实现这些功能。

# 安装 Axios

Axios 是一个非常流行的 HTTP 客户端库,可以用来在 Vue 组件中发送 API 请求。

  1. 安装 Axios

    在项目根目录下运行以下命令:

    pnpm add axios
  2. 在组件中引入 Axios

    import axios from 'axios'

    之后你就可以在 Vue 组件中使用 Axios 发送请求。

# 封装与后端 API 的交互逻辑

在根目录的 src 文件夹下创建 services/api.js ,并写入:

import axios from 'axios';
// 创建 Axios 实例,定义 API 的基础 URL
const api = axios.create({
    baseURL: 'http://localhost:3000/api'  // 后端 API 的基础 URL
});
// 获取 Biomarkers 数据
export const getBiomarkers = () => api.get('/biomarkers');
// 获取筛选选项
export const getFilters = () => api.get('/biomarkers/filters');
// 获取特定 Biomarker 的详细信息
export const getBiomarkerDetails = (id) => api.get(`/biomarkers/${id}`);

# 详细解释

  1. axios.create() :
    • 使用 axios.create() 方法创建了一个自定义的 axios 实例,所有请求都会以 http://localhost:3000/api 作为基础 URL。
    • 这样做的好处是,如果后端地址或端点路径改变,你只需要在一个地方更新 baseURL ,不必逐个修改每个请求的完整 URL。
  2. getBiomarkers() :
    • 发送 GET 请求到 /biomarkers ,用于获取所有的 biomarker 数据。
    • 请求的完整 URL 是 http://localhost:3000/api/biomarkers
  3. getFilters() :
    • 发送 GET 请求到 /biomarkers/filters ,用于获取筛选选项。
    • 请求的完整 URL 是 http://localhost:3000/api/biomarkers/filters
  4. getBiomarkerDetails(id) :
    • 发送 GET 请求到 /biomarkers/:id ,用于获取特定 ID 的 biomarker 详细信息。
    • 请求的完整 URL 是 http://localhost:3000/api/biomarkers/{id} ,其中 {id} 是动态传递的。
  5. createBiomarker(biomarker) :
    • 发送 POST 请求到 /biomarkers ,用于在后端创建一个新的 biomarker。 biomarker 是传递的请求体数据。
    • 请求的完整 URL 是 http://localhost:3000/api/biomarkers

# 作用

  • 该文件简化了组件中 API 请求的调用。在 Vue 组件中,可以直接通过 getBiomarkers()getFilters() 来获取数据,而不需要在每个组件中重复书写 axios.get() 请求逻辑。
  • 通过将所有 API 调用放在一个 api.js 文件中,您可以更轻松地管理 API 的端点、逻辑和错误处理。

# 组件中的使用

在 Vue 组件中,我们引入这些封装好的 API 方法来调用后端

# Biomarker 列表页面

BiomarkerList.vue 用于展示 Biomarker 列表,在挂载时通过 Axios 发送请求获取数据。

首先实现一个基本页面结构,后续逐步添加功能

我们计划构建一个包含以下部分的页面:

  1. 侧边栏:用于筛选条件的展示。
  2. 主内容区:用于显示 Biomarker 的数据表格。

我们使用 Container 布局容器 来进行整体布局, Table 表格 用于展示数据, Form 表单 来实现筛选表单

先写一个基本结构:

<template>
    <el-container>
        <!-- 侧栏 -->
        <el-aside width="300px">
            <el-menu default-active="1" class="filter-menu">
                <el-menu-item index="1">
                    <el-icon>
                        <Filter />
                    </el-icon>
                    <span>Filter</span>
                </el-menu-item>
            </el-menu>
        </el-aside>
        <!-- 主体内容 -->
        <el-container>
            <el-main>
                <!-- 数据表格 -->
                <el-table stripe style="width: 100%">
                    <el-table-column prop="ID" label="ID"></el-table-column>
                    <el-table-column prop="Biomarker(abbr.)" label="Biomarker"></el-table-column>
                    <el-table-column prop="Category" label="Category"></el-table-column>
                    <el-table-column prop="Application" label="Application"></el-table-column>
                    <el-table-column prop="PMID" label="PMID"></el-table-column>
                </el-table>
            </el-main>
        </el-container>
    </el-container>
</template>
<script setup>
import { Filter } from '@element-plus/icons-vue';
</script>
<style scoped>
.el-aside {
    background-color: #f5f5f5;
    padding: 20px;
}
.el-main {
    padding: 20px;
}
.filter-menu {
    margin-bottom: 20px;
}
</style>

我们使用了 Element Plus 的布局组件 <el-container> 来分割页面,左侧是固定宽度为 300px 的 <el-aside> 侧边栏,用于展示一个简单的筛选菜单,右侧是 <el-main> 主内容区域,包含一个通过 <el-table> 定义的数据表格,用于展示 Biomarker 的数据。样式上,侧边栏背景为浅灰色,整体布局有适当的内边距。

下一步,我们在这个页面上添加数据和功能:

  • 动态加载 Biomarker 数据到表格中;
  • 在侧边栏中添加筛选条件;
  • 实现分页、筛选和表格排序功能。

# 加载 Biomarker 数据

由于我们的数据比较少,我们希望在页面初次加载时直接加载全部 Biomarker 数据,然后在前端进行分页

<script setup>
import { ref, reactive, onMounted } from 'vue';
import { getBiomarkers } from '../services/api';
const loading = ref(true); // 加载状态
const biomarkers = ref([]); // 保存从 API 加载的全部 Biomarker 数据
const filteredBiomarkers = ref([]); // 保存筛选后的 Biomarker 数据
const paginatedBiomarkers = ref([]); // 保存当前分页的数据
const currentPage = ref(1); // 当前页码
const pageSize = ref(20); // 每页显示条数
// 请求所有数据
async function fetchAllBiomarkers() {
    try {
        loading.value = true;
        const response = await getBiomarkers({ limit: 10000 }); // 获取所有数据
        biomarkers.value = response.data;
        applyFilters();
    } catch (error) {
        console.error('Error fetching biomarkers:', error);
    } finally {
        loading.value = false;
    }
}
// 分页功能
function handlePageChange(page) {
    currentPage.value = page;
    updatePaginatedBiomarkers();
}
// 处理分页大小更改
function handleSizeChange(size) {
    pageSize.value = size; // 更新 pageSize
    currentPage.value = 1; // 重置为第一页
    updatePaginatedBiomarkers(); // 重新计算分页数据
}
// 更新分页数据
function updatePaginatedBiomarkers() {
    const start = (currentPage.value - 1) * pageSize.value;
    const end = start + pageSize.value;
    paginatedBiomarkers.value = filteredBiomarkers.value.slice(start, end);
}
// 初次加载数据
onMounted(fetchBiomarkers);
</script>

更新模板

<template>
    <el-container>
        <!-- 侧栏 -->
        <el-aside width="300px">
            <el-menu default-active="1" class="filter-menu">
                <el-menu-item index="1">
                    <el-icon>
                        <Filter />
                    </el-icon>
                    <span>Filter</span>
                </el-menu-item>
            </el-menu>
        </el-aside>
        <!-- 主体内容 -->
        <el-container>
            <el-main>
                <!-- 数据表格 -->
                <el-table v-loading="loading" :data="paginatedBiomarkers" stripe style="width: 100%">
                    <el-table-column sortable prop="ID" label="ID"></el-table-column>
                    <el-table-column sortable prop="Biomarker" label="Biomarker"></el-table-column>
                    <el-table-column sortable prop="Category" label="Category"></el-table-column>
                    <el-table-column sortable prop="Application" label="Application"></el-table-column>
                    <el-table-column prop="PMID" label="PMID"></el-table-column>
                </el-table>
                <el-pagination background @current-change="handlePageChange" @size-change="handleSizeChange"
                    :current-page="currentPage" :page-size="pageSize" :page-sizes="[20, 50, 100, 150, 200]"
                    layout="total, sizes, prev, pager, next, jumper" :total="filteredBiomarkers.length"
                    class="pagination" />
            </el-main>
        </el-container>
    </el-container>
</template>

# 添加筛选条件

实现筛选逻辑

<script setup>
import { Filter } from '@element-plus/icons-vue';
import { getFilters } from '../services/api';
// 筛选条件
const filters = reactive({
    category: '',
    source: '',
    application: ''
});
// 筛选选项
const filterOptions = reactive({
    Category: [],
    Source: [],
    Application: []
});
// 筛选功能
function applyFilters() {
    console.log('Applying filters...');  // 添加调试信息
    console.log('Current Filters:', filters);  // 检查当前筛选条件
    filteredBiomarkers.value = biomarkers.value.filter(biomarker => {
        return (
            (filters.category === '' || biomarker.Category === filters.category) &&
            (filters.source === '' || biomarker.Source === filters.source) &&
            (filters.application === '' || biomarker.Application === filters.application)
        );
    });
    console.log('Filtered Biomarkers:', filteredBiomarkers.value);  // 检查筛选后的数据
    updatePaginatedBiomarkers();
}
// 获取筛选选项
async function fetchFilterOptions() {
    try {
        console.log("Calling /biomarkers/filters...");
        const response = await getFilters();
        console.log("Filter Options API Response:", response.data);
        Object.assign(filterOptions, response.data);
    } catch (error) {
        console.error('Error fetching filter options:', error);
    }
}
</script>

在侧边栏中添加筛选表单:

<template>
    <el-container>
        <!-- 侧栏 -->
        <el-aside width="300px">
            <el-menu default-active="1" class="filter-menu">
                <el-menu-item index="1">
                    <el-icon>
                        <Filter />
                    </el-icon>
                    <span>Filter</span>
                </el-menu-item>
            </el-menu>
            <el-form :model="filters" label-width="80px">
                <el-form-item label="Category">
                    <el-select clearable v-model="filters.category" placeholder="Select Category">
                        <el-option v-for="category in filterOptions.Category" :key="category" :label="category"
                            :value="category"></el-option>
                    </el-select>
                </el-form-item>
                <el-form-item label="Source">
                    <el-select clearable v-model="filters.source" placeholder="Select Source">
                        <el-option v-for="source in filterOptions.Source" :key="source" :label="source"
                            :value="source"></el-option>
                    </el-select>
                </el-form-item>
                <el-form-item label="Application">
                    <el-select clearable v-model="filters.application" placeholder="Select Application">
                        <el-option v-for="application in filterOptions.Application" :key="application"
                            :label="application" :value="application"></el-option>
                    </el-select>
                </el-form-item>
                <el-button type="primary" @click="applyFilters">Apply Filters</el-button>
            </el-form>
        </el-aside>
        <!-- 主体内容 -->
        <el-container>
            <el-main>
                <!-- 数据表格 -->
                <el-table v-loading="loading" :data="paginatedBiomarkers" stripe style="width: 100%">
                    <el-table-column sortable="true" prop="ID" label="ID"></el-table-column>
                    <el-table-column sortable="true" prop="Biomarker(abbr.)" label="Biomarker"></el-table-column>
                    <el-table-column sortable="true" prop="Category" label="Category"></el-table-column>
                    <el-table-column sortable="true" prop="Application" label="Application"></el-table-column>
                    <el-table-column sortable="true" prop="PMID" label="PMID"></el-table-column>
                </el-table>
                <el-pagination background @current-change="handlePageChange" @size-change="handleSizeChange"
                    :current-page="currentPage" :page-size="pageSize" :page-sizes="[20, 50, 100, 150, 200]"
                    layout="total, sizes, prev, pager, next, jumper" :total="filteredBiomarkers.length"
                    class="pagination" />
            </el-main>
        </el-container>
    </el-container>
</template>

完成后,此页面已经能够动态加载数据、筛选、分页,并显示 Biomarker 信息。

# 整体功能

这部分主要实现了一个带有侧边过滤功能和主表格显示功能的页面布局

  1. 总体布局,使用了 Element Plus 的布局容器 el-container

    • el-aside :左侧的过滤菜单区域,宽度为 300px。
    • el-main :右侧的主内容区域,用于显示数据表格和分页。
  2. 侧边过滤区域,包含一个标题菜单和一个过滤表单。

    • 过滤菜单:使用 el-menu 提供一个可点击的 “Filter” 标题。

    • 过滤表单

      • 使用 el-form 表单结构。
      • 包括 3 个 el-form-item 表单项:
        • Category(分类)
        • Source(来源)
        • Application(应用)
      • 每个表单项内使用了 el-select 下拉框,选项由 filterOptions 数据动态生成。
      • 提供了一个 “Apply Filters” 按钮,绑定了 applyFilters 方法。
  3. 主体内容区域

    • 数据表格使用了 el-table

      • 数据源绑定到 paginatedBiomarkers ,表格支持分页。
      • 各列使用 el-table-column 定义,包括 ID、Biomarker(生物标记物)、Category、Application 和 PMID。
      • 表格支持按列排序(通过 sortable="true" )。
      • 表格显示时支持加载动画(通过 v-loading="loading" )。
    • 分页功能使用了 el-pagination 实现分页控制,支持:

      • 当前页数切换(绑定 handlePageChange 方法)。
      • 每页条数修改(绑定 handleSizeChange 方法)。
      • 可选择的分页大小 [20, 50, 100, 150, 200]
      • 动态显示总条目数,绑定到 filteredBiomarkers.length
  4. 绑定的数据和方法

    • filters :用于存储表单中用户选择的过滤条件(包括 Category、Source 和 Application)。

    • filterOptions :包含表单下拉框的选项数据。

    • paginatedBiomarkers :表格当前页显示的数据。

    • filteredBiomarkers :经过过滤后的完整数据集,用于分页计算总条数。

    • 方法

      • applyFilters :点击 “Apply Filters” 按钮后,执行过滤逻辑。
      • handlePageChange :处理分页页码切换。
      • handleSizeChange :处理每页显示条数变化。

# Biomarker 详情页面

BiomarkerDetail.vue 用于展示 Biomarker 详情,包括基本信息、来源文献、统计信息、外链等信息。

我们计划构建一个包含以下部分的页面:

  1. 侧边栏:用于导航。
  2. 右侧标题栏:用于显示 Biomarker 的名字
  3. 主内容区:用于显示 Biomarker 的详细信息。

我们使用 element-plus 的 Anchor 锚点 组件和 Descriptions 描述列表 组件来完成这个页面,整体布局同样使用 Container 布局容器

<template>
    <el-container>
        <!-- 侧栏:锚点导航 -->
        <el-aside width="300px">
            <el-anchor>
                <!-- 为每个表格标题添加锚点链接 -->
                <el-anchor-link href="#basic-info" title="Basic Information" />
                <el-anchor-link href="#article-info" title="Article Information" />
                <el-anchor-link href="#experiment-info" title="Experimental and Sample Statistics" />
            </el-anchor>
        </el-aside>
        <el-container>
            <el-header>
                <div>
                    <!-- 标题 -->
                    <h1> biomarker["Biomarker(abbr.)"] || "Loading..." }}</h1>
                    <!-- 加载和错误状态 -->
                    <div v-if="isLoading">Loading...</div>
                    <div v-if="error">{{ error }}</div>
                    <hr />
                </div>
            </el-header>
            <el-main>
                <!-- 基础信息表 -->
                <el-descriptions id="basic-info" class="margin-top" title="Basic Information" :column="1" size="large"
                    border>
                    <el-descriptions-item v-for="field in basicInfoFields" :key="field.key">
                        <template #label>
                            <div class="cell-item">
                                <el-icon :style="iconStyle">
                                    <user />
                                </el-icon>
                                {{ field.label }}
                            </div>
                        </template>
                        {{ biomarker[field.key] === 0 ? 0 : biomarker[field.key] || "N/A" }}
                    </el-descriptions-item>
                </el-descriptions>
                <!-- 文章信息表 -->
                <el-descriptions id="article-info" class="margin-top" title="Article Information" :column="1"
                    size="large" border>
                    <el-descriptions-item v-for="field in articleInfoFields" :key="field.key">
                        <template #label>
                            <div class="cell-item">
                                <el-icon :style="iconStyle">
                                    <user />
                                </el-icon>
                                {{ field.label }}
                            </div>
                        </template>
                        {{ biomarker[field.key] === 0 ? 0 : biomarker[field.key] || "N/A" }}
                    </el-descriptions-item>
                </el-descriptions>
                <!-- 实验与样本信息表 -->
                <el-descriptions id="experiment-info" class="margin-top" title="Experimental and Sample Statistics"
                    :column="1" size="large" border>
                    <el-descriptions-item v-for="field in experimentFields" :key="field.key">
                        <template #label>
                            <div class="cell-item">
                                <el-icon :style="iconStyle">
                                    <user />
                                </el-icon>
                                {{ field.label }}
                            </div>
                        </template>
                        {{ biomarker[field.key] === 0 ? 0 : biomarker[field.key] || "N/A" }}
                    </el-descriptions-item>
                </el-descriptions>
            </el-main>
        </el-container>
    </el-container>
</template>

数据获取与展示逻辑

<script setup>
import { computed, ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { getBiomarkerDetails } from '../services/api';
// 路由和状态
const route = useRoute();
const biomarker = ref([]); // 存储 biomarker 详情
const isLoading = ref(false); // 加载状态
const error = ref(null); // 错误信息
// 图标样式
const iconStyle = computed(() => ({
    marginRight: '8px',
}));
// 配置化字段
const basicInfoFields = [
    { label: 'ID', key: 'ID' },
    { label: 'Name', key: 'Biomarker(abbr.)' },
    { label: 'Category', key: 'Category' },
    { label: 'Application', key: 'Application' },
    { label: 'Discription', key: 'Discription' },
    { label: 'Detail', key: 'Detail' },
];
const articleInfoFields = [
    { label: 'PMID', key: 'PMID' },
    { label: 'Journal', key: 'Journal' },
    { label: 'Corresponding author', key: 'Corresponding author' },
    {
        label: "Corresponding author's unit", key: "Corresponding author's unit"
    },
    { label: 'Publication year', key: 'Publication year' },
];
const experimentFields = [
    { label: 'Experiment methods', key: 'Experiment methods' },
    { label: 'Statictics', key: 'Statictics' },
    { label: 'Region', key: 'Region' },
    { label: 'Race', key: 'Race' },
    { label: 'Samples', key: 'Samples' },
    { label: 'Age', key: 'Age' },
    { label: 'Gender(Male/Female)', key: 'Gender(m/f)' },
    { label: 'Source', key: 'Source' },
];
// 数据获取
onMounted(async () => {
    const biomarkerId = route.params.id;
    isLoading.value = true;
    try {
        const response = await getBiomarkerDetails(biomarkerId);
        biomarker.value = response.data;
        // console.log(biomarker.value);
        // console.log(Object.keys(biomarker.value));
    } catch (err) {
        error.value = 'Failed to fetch biomarker details';
    } finally {
        isLoading.value = false;
    }
});
</script>
<style scoped>
.el-aside {
    /* background-color: #f5f5f5; */
    padding: 20px;
    height: 100vh;
    overflow: auto;
}
.el-anchor {
    position: sticky;
    top: 20px;
}
.el-header {
    padding: 20px;
    font-size: 30px;
    font-weight: bold;
}
.margin-top {
    margin-top: 20px;
}
.cell-item {
    display: flex;
    align-items: center;
}
</style>

# 整体功能

这段代码实现了一个带锚点导航的生物标记物详情页面,通过左侧锚点导航快速跳转到不同信息段落,同时在右侧主内容区展示生物标记物的详细信息。

  1. 页面布局:使用了 Element Plus 的布局容器 el-container

    • 左侧锚点导航
      • 位于 el-aside ,宽度 300px,包含 el-anchor
    • 右侧内容区域
      • 使用 el-container ,展示生物标记物的详细信息。
  2. 锚点导航

    • 使用 el-anchor 定义锚点导航,提供跳转到页面中各信息段落的锚点链接(如 Basic Information、Article Information、Experimental and Sample Statistics)
  3. 详细信息展示:通过多个 el-descriptions 组件分段展示生物标记物详情:

    • 基础信息(Basic Information):展示基本的生物标记物信息,如 ID、名称、分类、应用等。

    • 文章信息(Article Information):展示与生物标记物相关的文章数据,如 PMID、期刊名、通讯作者等。

    • 实验与样本统计信息(Experimental and Sample Statistics):展示实验细节,如实验方法、样本来源、种族、年龄、性别等。

    • 每段表格:

      • 使用 :column="1" 强制单列布局。
      • 支持显示表头和数据,缺失字段自动显示为 "N/A"
  4. 加载状态与错误提示

    • 页面加载时显示 “Loading...” 状态,通过 isLoading 控制。
    • 如果数据获取失败,通过 error 显示错误信息。
  5. 数据动态加载

    • 页面加载时,根据路由参数 id 通过 API 获取生物标记物详细数据。
    • 数据绑定到 biomarker ,通过字段配置化动态渲染不同表格内容。
    • 缺省值处理:当某字段值为 0 时显示 0,其他空值显示为 "N/A"
  6. 数据绑定和方法

    • biomarker :存储当前生物标记物的详细数据。
    • isLoadingerror :分别用于控制加载状态和错误信息显示。
    • basicInfoFieldsarticleInfoFieldsexperimentFields :通过字段配置表定义各表格展示的字段和对应的键值。
    • 页面加载时自动调用 onMounted 钩子,发送请求获取数据。
    • 锚点跳转功能依赖 el-anchor 的内置功能。

# 路由设置

为了在点击 biomarker 时跳转到详细信息页面,我们需要在 Vue Router 中配置路由。确保在 router/index.js 中定义相应的路由:

import { createRouter, createWebHistory } from 'vue-router'
import BiomarkerList from '../components/BiomarkerList.vue'
import BiomarkerDetail from '../components/BiomarkerDetail.vue'
const routes = [
  { path: '/', name: 'BiomarkerList', component: BiomarkerList },
  { path: '/biomarkers/:id', name: 'BiomarkerDetail', component: BiomarkerDetail },
]
const router = createRouter({
  history: createWebHistory(),
  routes,
})
export default router

# 预览

现在我们启动开发环境,预览页面:

在根目录 ADDB/ 下启动前端项目

pnpm run dev

ADDB/backend/ 下启动后端项目

pnpm start