从零搭建”一物一码”合格证查询系统——Vue 3 + Vite 前端架构详解

系列文章(3/5):本篇聚焦前端架构设计。我们将拆解为什么选择”双 SPA”方案,深入管理后台与公众查询端的核心模块,并覆盖 Vite 构建、前后端交互和移动端适配等关键主题。


1. 双 SPA 架构设计理念

很多团队在项目初期会把管理后台和面向公众的页面塞进同一个 SPA。随着业务增长,巨型 bundle 导致公众页面加载缓慢,而后台专属依赖又污染了公众端的 tree-shaking 效果。我们从立项之初就做了一个决定——将管理后台与公众查询端拆分为两个独立的 Vue 3 SPA

1
2
3
4
5
6
7
8
9
frontend/
├── admin/ # 管理后台:功能全面,面向内部用户
│ ├── src/
│ ├── package.json
│ └── vite.config.ts
└── cert-view/ # 公众查询端:轻量快速,面向终端消费者
├── src/
├── package.json
└── vite.config.ts

做出这一选择基于四个核心考量:

体积差异显著。 Admin 端引入了 ECharts 6.0(可视化图表)、@pureadmin/table(高级数据表格)、Element Plus 完整组件库和 Pinia 状态管理——这些加在一起,仅 vendor chunk 就超过 800 KB(gzip 后约 260 KB)。而公众端只需 axios、html5-qrcode 和 Element Plus 的少量组件,首屏 JS 可以压缩到 80 KB 以内。当用户在生产线上用手机扫码时,每多 100 KB 就意味着多等将近一秒(在 3G/4G 弱网环境下尤为明显)。

部署策略不同。 Admin 部署在内部网络或通过 VPN 访问,前面有登录拦截;cert-view 则是完全公开的,直接通过 CDN 分发静态资源。两者的 nginx 配置、缓存策略、CORS 规则截然不同,拆分后各自独立配置互不干扰。

开发节奏独立。 后台功能迭代频繁(新增报表、调整权限),而公众端相对稳定。双 SPA 让两个子项目拥有独立的 node_modules 和构建流水线,后台的频繁发布不会影响公众端的缓存。

共享构建基础。 虽然是两个项目,但底层的 Vite 配置(代理规则、环境变量命名、TypeScript 配置)保持一致,降低了维护成本。


2. Admin 管理后台核心模块

Admin 端的技术选型为 Vue 3.5 + Vite + Element Plus 2.11 + Pinia 3.0 + ECharts 6.0,配合 @pureadmin/table@vueuse/core。下面逐一展开关键模块。

2.1 Pinia 状态管理

用户登录态、JWT Token、权限列表全部通过 Pinia store 管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// stores/user.ts —— 用户状态管理
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { loginApi, refreshTokenApi, type UserInfo } from "@/api/auth";

export const useUserStore = defineStore("user", () => {
const token = ref<string>(localStorage.getItem("access_token") || "");
const userInfo = ref<UserInfo | null>(null);
const role = ref<"superadmin" | "admin" | "operator">("operator");

// 计算属性:判断是否有管理权限
const isAdmin = computed(
() => role.value === "superadmin" || role.value === "admin",
);

// 登录:保存 access + refresh token
async function login(username: string, password: string) {
const res = await loginApi(username, password);
token.value = res.access;
localStorage.setItem("access_token", res.access);
localStorage.setItem("refresh_token", res.refresh);
userInfo.value = res.user;
role.value = res.user.role;
}

// Token 刷新:使用 refresh token 获取新的 access token
async function refreshToken() {
const refresh = localStorage.getItem("refresh_token");
if (!refresh) throw new Error("No refresh token");
const res = await refreshTokenApi(refresh);
token.value = res.access;
localStorage.setItem("access_token", res.access);
}

return { token, userInfo, role, isAdmin, login, refreshToken };
});

2.2 动态表单与字典下拉

合格证录入涉及大量枚举字段(产品类型、检验标准、执行规范等),我们将其抽象为 DropdownOption 字典系统。后端维护字典表,前端通过 API 拉取后填充 Element Plus 的 <el-select>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<!-- components/DictSelect.vue —— 通用字典下拉组件 -->
<template>
<el-select
v-model="modelValue"
:placeholder="placeholder"
filterable
clearable
>
<el-option
v-for="item in options"
:key="item.id"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue";
import { fetchDictOptions } from "@/api/dict";

const props = defineProps<{
modelValue: string;
dictType: string; // 字典类型标识,如 'product_type'、'inspect_standard'
placeholder?: string;
}>();

const emit = defineEmits(["update:modelValue"]);
const options = ref<{ id: number; label: string; value: string }[]>([]);

onMounted(async () => {
// 根据字典类型从后端加载选项
options.value = await fetchDictOptions(props.dictType);
});
</script>

这样,当业务人员在后台新增一种检验标准时,前端无需发布即可自动生效。

2.3 ECharts 数据可视化

管理后台首页的仪表盘包含两个关键图表:即将过期证书趋势图扫码统计图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// composables/useDashboardChart.ts —— 仪表盘图表组合式函数
import { onMounted, ref } from "vue";
import * as echarts from "echarts/core";
import { BarChart, LineChart } from "echarts/charts";
import {
GridComponent,
TooltipComponent,
LegendComponent,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";

// 按需注册组件,避免引入完整 ECharts
echarts.use([
BarChart,
LineChart,
GridComponent,
TooltipComponent,
LegendComponent,
CanvasRenderer,
]);

export function useExpiryChart(containerRef: Ref<HTMLElement | null>) {
let chart: echarts.ECharts | null = null;

onMounted(() => {
if (!containerRef.value) return;
chart = echarts.init(containerRef.value);
chart.setOption({
tooltip: { trigger: "axis" },
xAxis: { type: "category", data: [] }, // 日期轴
yAxis: { type: "value", name: "证书数量" },
series: [
{
name: "即将过期",
type: "bar",
data: [],
itemStyle: { color: "#E6A23C" },
},
{
name: "已过期",
type: "bar",
data: [],
itemStyle: { color: "#F56C6C" },
},
],
});
});

// 数据更新方法,由父组件调用
function updateData(dates: string[], expiring: number[], expired: number[]) {
chart?.setOption({
xAxis: { data: dates },
series: [{ data: expiring }, { data: expired }],
});
}

return { updateData };
}

2.4 高级数据表格与批量操作

@pureadmin/table 封装了 Element Plus 的 el-table,提供了服务端分页、排序和筛选的统一接口。结合批量操作,管理员可以选中多行后执行批量绑定码、解绑、删除或导出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- views/CertList.vue —— 证书列表页(简化版) -->
<template>
<div class="cert-list">
<!-- 批量操作工具栏:仅管理员角色可见 -->
<el-button-group v-if="userStore.isAdmin" class="batch-bar">
<el-button :disabled="!selectedRows.length" @click="batchBind">
批量绑定 ({{ selectedRows.length }})
</el-button>
<el-button :disabled="!selectedRows.length" @click="batchExport">
批量导出
</el-button>
<!-- operator 角色不可见删除按钮 -->
<el-button
v-if="userStore.role === 'superadmin'"
type="danger"
:disabled="!selectedRows.length"
@click="batchDelete"
>
批量删除
</el-button>
</el-button-group>

<pure-table
:data="tableData"
:columns="columns"
:pagination="pagination"
@selection-change="onSelectionChange"
@page-change="fetchData"
@sort-change="onSortChange"
/>
</div>
</template>

角色控制的关键在于:后端 API 会校验权限(即使前端隐藏了按钮,未授权请求也会返回 403),前端的 v-if 只是提供更友好的 UI 体验。安全永远由后端兜底。


3. 公众端 cert-view 核心模块

公众端的技术选型更加精简:Vue 3.5 + Vite + axios + html5-qrcode 2.3,配合少量 Element Plus 组件。整个 SPA 只有三个核心页面。

3.1 Home.vue:证书号查询

首页提供一个简洁的搜索框,用户输入合格证编号即可查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<script setup lang="ts">
// Home.vue —— 首页证书查询
import { ref } from "vue";
import { lookupCertByNumber, type CertLookupResult } from "../api";

const certNumber = ref("");
const searching = ref(false);
const searchResult = ref<CertLookupResult | null>(null);

async function handleSearch() {
searching.value = true;
try {
// 调用公开 API,无需认证
searchResult.value = await lookupCertByNumber(certNumber.value);
} finally {
searching.value = false;
}
}
</script>

<template>
<div class="home-container">
<h1>产品合格证查询</h1>
<el-input
v-model="certNumber"
placeholder="请输入合格证编号"
size="large"
@keyup.enter="handleSearch"
>
<template #append>
<el-button :loading="searching" @click="handleSearch">查询</el-button>
</template>
</el-input>

<!-- 查询结果展示 -->
<CertCard v-if="searchResult" :data="searchResult" />
</div>
</template>

3.2 CertView.vue:证书详情与数字签名验证

用户通过扫码进入证书详情页时,URL 中携带唯一 token。组件挂载后用这个 token 调用 API,渲染完整的证书信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<script setup lang="ts">
// CertView.vue —— 证书详情展示
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { getCertByToken, type CertDetail } from "../api";

const route = useRoute();
const cert = ref<CertDetail | null>(null);
const loading = ref(true);

onMounted(async () => {
const token = route.params.token as string;
try {
cert.value = await getCertByToken(token);
} catch (err) {
// 无效 token 或证书不存在
console.error("证书加载失败", err);
} finally {
loading.value = false;
}
});
</script>

<template>
<div v-if="cert" class="cert-detail">
<!-- 基本信息区块 -->
<section class="info-section">
<h2>{{ cert.productName }}</h2>
<p>证书编号:{{ cert.certNumber }}</p>
<p>生产企业:{{ cert.manufacturer }}</p>
<p>检验日期:{{ cert.inspectionDate }}</p>
<p>有效期至:{{ cert.expiryDate }}</p>
</section>

<!-- 数字签名验证状态 -->
<section class="signature-section">
<!-- 绿色:签名验证通过 -->
<div
v-if="cert.signatureStatus === 'verified'"
class="sig-badge sig-verified"
>
<el-icon><CircleCheckFilled /></el-icon>
数字签名验证通过
</div>
<!-- 红色:签名验证失败(可能被篡改) -->
<div
v-else-if="cert.signatureStatus === 'failed'"
class="sig-badge sig-failed"
>
<el-icon><CircleCloseFilled /></el-icon>
签名验证失败,证书可能被篡改
</div>
<!-- 灰色:未签名 -->
<div v-else class="sig-badge sig-none">
<el-icon><InfoFilled /></el-icon>
此证书未使用数字签名
</div>

<!-- 可展开的技术详情 -->
<el-collapse v-if="cert.signatureStatus !== 'none'">
<el-collapse-item title="签名技术详情">
<p>算法:{{ cert.signatureDetail?.algorithm }}</p>
<p>密钥 ID:{{ cert.signatureDetail?.keyId }}</p>
<p>签署时间:{{ cert.signatureDetail?.signedAt }}</p>
<p>摘要算法:{{ cert.signatureDetail?.digest }}</p>
</el-collapse-item>
</el-collapse>
</section>
</div>
</template>

签名状态使用三色系统——绿色(verified)、红色(failed)、灰色(none)——即使是非技术用户也能一眼判断证书的可信度。

3.3 ScanView.vue:摄像头扫码

html5-qrcode 库负责调用设备摄像头识别二维码,扫码成功后自动跳转到证书详情页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<script setup lang="ts">
// ScanView.vue —— 摄像头扫码页面
import { onMounted, onUnmounted, ref } from "vue";
import { Html5Qrcode } from "html5-qrcode";
import { useRouter } from "vue-router";

const router = useRouter();
const scannerRef = ref<HTMLElement | null>(null);
let scanner: Html5Qrcode | null = null;

onMounted(async () => {
scanner = new Html5Qrcode("qr-reader");
await scanner.start(
{ facingMode: "environment" }, // 优先使用后置摄像头
{ fps: 10, qrbox: { width: 250, height: 250 } },
(decodedText) => {
// 扫码成功:从 URL 中提取 token 并跳转
const url = new URL(decodedText);
const token = url.pathname.split("/").pop();
if (token) {
scanner?.stop();
router.push({ name: "CertView", params: { token } });
}
},
() => {}, // 忽略扫码失败的帧
);
});

onUnmounted(() => {
scanner?.stop(); // 组件卸载时释放摄像头
});
</script>

<template>
<div class="scan-container">
<h2>扫描产品二维码</h2>
<div id="qr-reader" class="qr-scanner-box" />
<p class="scan-tip">请将二维码置于框内,自动识别后跳转</p>
</div>
</template>

3.4 API 层:类型化的 axios 封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// api.ts —— 公众端 API 层
import axios from "axios";

const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE || "/api",
timeout: 10000,
});

// 响应拦截器:统一错误处理
http.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 429) {
// 触发限流时给出友好提示
ElMessage.warning("查询过于频繁,请稍后再试");
}
return Promise.reject(err);
},
);

// ----- 类型定义 -----
export interface CertLookupResult {
certNumber: string;
productName: string;
manufacturer: string;
status: "valid" | "expired" | "revoked";
viewUrl: string;
}

export interface CertDetail {
certNumber: string;
productName: string;
manufacturer: string;
inspectionDate: string;
expiryDate: string;
signatureStatus: "verified" | "failed" | "none";
signatureDetail?: {
algorithm: string;
keyId: string;
signedAt: string;
digest: string;
};
}

// ----- API 方法 -----
export const lookupCertByNumber = (num: string) =>
http.get<any, CertLookupResult>("/public/cert/lookup/", {
params: { number: num },
});

export const getCertByToken = (token: string) =>
http.get<any, CertDetail>(`/public/cert/${token}/`);

4. Vite 构建配置

两个 SPA 共享类似的 Vite 配置骨架,核心差异在于代理路径和构建输出目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// vite.config.ts —— 通用配置模式(以 cert-view 为例)
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
plugins: [vue()],
server: {
port: 5174, // cert-view 用 5174,admin 用 5173
proxy: {
// 开发环境:将 /api 请求代理到 Django 后端
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
rollupOptions: {
output: {
// 手动分包:将大型库单独打包
manualChunks: {
vendor: ["vue", "vue-router", "axios"],
},
},
},
},
});

环境变量通过 .env 文件管理:

1
2
3
4
5
# .env.development
VITE_API_BASE=http://localhost:8000/api

# .env.production
VITE_API_BASE=/api

路由懒加载确保只有访问到的页面才会被下载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// router/index.ts —— 路由懒加载
const routes = [
{ path: "/", name: "Home", component: () => import("../views/Home.vue") },
{
path: "/scan",
name: "Scan",
component: () => import("../views/ScanView.vue"),
},
{
path: "/cert/:token",
name: "CertView",
component: () => import("../views/CertView.vue"),
},
];

5. 前后端交互模式

Admin 端和公众端与后端的交互方式存在本质差异。

Admin 端——JWT 认证流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// admin/src/utils/http.ts —— Admin 端 axios 实例
const adminHttp = axios.create({ baseURL: "/api" });

// 请求拦截器:附加 JWT Token
adminHttp.interceptors.request.use((config) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});

// 响应拦截器:处理 Token 过期自动刷新
adminHttp.interceptors.response.use(
(res) => res.data,
async (err) => {
if (err.response?.status === 401 && !err.config._retried) {
err.config._retried = true;
try {
const userStore = useUserStore();
await userStore.refreshToken();
// 用新 token 重试原始请求
err.config.headers.Authorization = `Bearer ${userStore.token}`;
return adminHttp(err.config);
} catch {
// Refresh 也失败了,跳转登录页
router.push("/login");
}
}
if (err.response?.status === 403) {
ElMessage.error("权限不足,请联系管理员");
}
return Promise.reject(err);
},
);

Token 刷新采用轮换机制(Rotation):每次 refresh 后旧的 refresh token 立即失效,新的 refresh token 随 access token 一起返回。这避免了 refresh token 被盗后的长期风险。

公众端——无需认证。 所有公众 API 均标记为 AllowAny,无需携带 Token。但后端对公众接口配置了严格的限流规则(如每 IP 每分钟 30 次查询),前端在收到 429 响应时展示友好提示。

Cookie 回退方案。 在开发环境中,Admin SPA(端口 5173)和 Django(端口 8000)跨端口通信时,某些浏览器的 Authorization header 可能被吞掉。为此后端同时支持从 Cookie 中读取 JWT,作为 header 方案的补充。


6. 移动端适配

公众端从设计之初就以移动优先为原则——毕竟 95% 以上的用户是在产品包装上用手机扫码访问的。

响应式布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/* cert-view/src/styles/mobile.css —— 移动端核心样式 */
.cert-detail {
max-width: 640px;
margin: 0 auto;
padding: 16px;
}

.sig-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 8px;
font-size: 15px;
}

/* 签名状态三色系统 */
.sig-verified {
background: #f0f9eb;
color: #67c23a;
}
.sig-failed {
background: #fef0f0;
color: #f56c6c;
}
.sig-none {
background: #f4f4f5;
color: #909399;
}

/* 小屏幕适配 */
@media (max-width: 480px) {
.cert-detail {
padding: 12px;
}
.qr-scanner-box {
width: 100%;
max-width: 300px;
margin: 0 auto;
}
}

微信内置浏览器兼容。 微信浏览器对摄像头 API 有特殊限制,html5-qrcode 在初始化时会自动检测 navigator.mediaDevices 的可用性。如果摄像头权限被拒绝或不可用,我们优雅降级为手动输入证书编号:

1
2
3
4
5
6
7
8
9
10
// 摄像头不可用时的降级处理
async function initScanner() {
try {
await scanner.start(/* ... */);
} catch (err) {
// 微信浏览器或用户拒绝摄像头权限时
cameraAvailable.value = false;
// UI 自动切换为手动输入模式
}
}

完整的扫码流程: 用户扫描产品上的二维码 → 微信/浏览器打开 cert-view URL(携带 token 参数)→ CertView.vue 挂载并请求证书数据 → 渲染证书信息 + 签名验证状态。整个流程不超过 2 秒(在 4G 网络环境下)。


小结

双 SPA 架构让管理后台可以”豪华配置”而不拖累公众端的加载速度。Admin 端通过 Pinia + Element Plus + ECharts 构建了功能完备的管理界面;cert-view 端则以极简依赖实现了扫码查询和签名验证的核心体验。Vite 的开发代理和构建分包策略让开发和部署都保持了高效。

下一篇预告: 第四篇我们将深入安全与防篡改设计——包括 Ed25519 数字签名的生成与验证流程、证书防伪机制、API 限流策略,以及如何防止恶意批量爬取证书数据。