手把手教你用 Vue3 打造企业级动态二维码生成器 - 支持批量下载与数据可视化》
文章大纲
-
引言:二维码在现代应用中的重要性
- 二维码在移动支付、信息传递、身份验证等场景的应用
- 企业级应用对批量生成二维码的需求
- 本文实现的解决方案亮点
-
效果展示(关键截图)
-
技术栈与准备工作
- Vue3 核心特性 (Composition API)
- QRCode.js 库介绍
- 安装依赖:
npm install qrcode
-
核心功能实现
- 动态数据获取与处理
- 批量二维码生成原理
- 分页与数据可视化
- 自定义样式与批量下载
-
关键代码解析(按功能模块展示)
关键代码展示技巧
在掘金文章中展示代码,建议使用 代码块 + 详细注释 的方式,让读者能够清晰理解每个功能模块的实现逻辑。
示例1:数据获取与处理
javascript
// 模拟从后端获取数据
const fetchData = async () => {
loading.value = true;
generatedCount.value = 0;
// 实际项目中替换为真实API调用
const mockData = [];
for (let i = 1; i <= 24; i++) {
mockData.push({
id: `EMP-${1000 + i}`,
name: names[Math.floor(Math.random() * names.length)],
// 其他字段...
});
}
dataList.value = mockData;
generateAllQRCodes();
};
/*
代码解析:
1. 使用async/await处理异步请求
2. 生成模拟数据作为演示(实际项目替换为API调用)
3. 将获取的数据存入响应式变量dataList
4. 调用generateAllQRCodes生成二维码
*/
示例2:二维码生成核心逻辑
javascript
// 为单条数据生成二维码
const generateQRCode = async (item) => {
try {
// 格式化数据为多行文本
const text = `ID: ${item.id}\n姓名: ${item.name}\n电话: ${item.phone}`;
// 使用QRCode生成DataURL
const dataUrl = await QRCode.toDataURL(text, {
width: qrSize.value,
margin: 2,
color: {
dark: colorDark.value, // 前景色
light: colorLight.value // 背景色
},
errorCorrectionLevel: 'H' // 高容错率
});
// 更新数据项
item.qrDataUrl = dataUrl;
item.qrContent = text;
} catch (error) {
console.error(`生成二维码失败: ${error}`);
}
};
/*
技术要点:
1. QRCode.toDataURL将文本转换为二维码图片
2. 支持自定义尺寸、颜色和容错级别
3. 错误处理保证应用健壮性
4. 将生成的图片存入数据对象,实现响应式更新
*/
示例3:批量下载功能实现
javascript
// 批量下载所有二维码
const downloadAllQRCodes = async () => {
if (dataList.value.length === 0) return;
for (const item of dataList.value) {
if (item.qrDataUrl) {
const link = document.createElement('a');
link.download = `${item.name}_${item.id}_二维码.png`;
link.href = item.qrDataUrl;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 添加延迟避免浏览器阻止连续下载
await new Promise(resolve => setTimeout(resolve, 300));
}
}
};
/*
实现思路:
1. 遍历所有数据项
2. 为每个二维码创建临时下载链接
3. 模拟点击实现下载
4. 添加300ms延迟避免浏览器安全策略限制
5. 清理临时创建的DOM元素
*/
- 代码块:完整代码
动态数据二维码生成器 - Vue 组件实现
下面是一个功能完整的动态二维码生成器 Vue 组件,可以从后端获取数据列表并为每条数据生成独立的二维码,支持自定义尺寸、颜色和批量下载功能。
vue
<template>
<div class="qr-generator-container">
<!-- 头部区域 -->
<div class="header">
<h1><i class="fas fa-qrcode"></i> 动态数据二维码生成器</h1>
<p class="subtitle">从后端获取数据列表,为每条数据生成独立二维码</p>
</div>
<!-- 控制面板 -->
<div class="control-panel">
<div class="config-group">
<h3><i class="fas fa-cog"></i> 二维码设置</h3>
<div class="config-item">
<label>二维码尺寸:{{ qrSize }}px</label>
<input type="range" min="100" max="300" v-model.number="qrSize">
</div>
<div class="config-item">
<label>前景色</label>
<div class="color-picker">
<input type="color" v-model="colorDark">
<div class="color-preview" :style="{ backgroundColor: colorDark }"></div>
</div>
</div>
<div class="config-item">
<label>背景色</label>
<div class="color-picker">
<input type="color" v-model="colorLight">
<div class="color-preview" :style="{ backgroundColor: colorLight }"></div>
</div>
</div>
</div>
<!-- 省略部分代码...完整代码见下方 -->
</div>
</div>
</template>
提示:为了节省篇幅,上面仅展示了模板的部分代码,完整代码请见下方代码块
vue
<template>
<div class="qr-generator-container">
<!-- 头部区域 -->
<div class="header">
<h1><i class="fas fa-qrcode"></i> 动态数据二维码生成器</h1>
<p class="subtitle">从后端获取数据列表,为每条数据生成独立二维码</p>
</div>
<!-- 控制面板 -->
<div class="control-panel">
<div class="config-group">
<h3><i class="fas fa-cog"></i> 二维码设置</h3>
<div class="config-item">
<label>二维码尺寸:{{ qrSize }}px</label>
<input type="range" min="100" max="300" v-model.number="qrSize">
</div>
<div class="config-item">
<label>前景色</label>
<div class="color-picker">
<input type="color" v-model="colorDark">
<div class="color-preview" :style="{ backgroundColor: colorDark }"></div>
</div>
</div>
<div class="config-item">
<label>背景色</label>
<div class="color-picker">
<input type="color" v-model="colorLight">
<div class="color-preview" :style="{ backgroundColor: colorLight }"></div>
</div>
</div>
</div>
<div class="config-group">
<h3><i class="fas fa-sliders-h"></i> 显示设置</h3>
<div class="config-item">
<label>每页显示数量</label>
<select v-model="itemsPerPage">
<option value="4">4</option>
<option value="6">6</option>
<option value="8">8</option>
<option value="12">12</option>
</select>
</div>
</div>
<div class="actions">
<button class="btn primary" @click="fetchData">
<i class="fas fa-sync-alt"></i> 获取数据
</button>
<button class="btn success" @click="downloadAllQRCodes" :disabled="dataList.length === 0">
<i class="fas fa-download"></i> 批量下载
</button>
</div>
</div>
<!-- 统计信息 -->
<div class="stats">
<div class="stat-card">
<div class="stat-value">{{ totalItems }}</div>
<div class="stat-label">总数据量</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ generatedQRCodes }}</div>
<div class="stat-label">已生成二维码</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ currentPage }}/{{ totalPages }}</div>
<div class="stat-label">当前页/总页</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<i class="fas fa-spinner fa-spin"></i>
<p>正在加载数据并生成二维码...</p>
<p>已生成 {{ generatedCount }} / {{ dataList.length }} 个二维码</p>
</div>
<!-- 空状态 -->
<div v-else-if="dataList.length === 0" class="empty-state">
<i class="fas fa-inbox"></i>
<h3>暂无数据</h3>
<p>点击"获取数据"按钮从后端获取数据</p>
<button class="btn primary" @click="fetchData">
<i class="fas fa-sync-alt"></i> 获取数据
</button>
</div>
<!-- 二维码网格 -->
<div v-else class="qr-grid">
<div v-for="(item, index) in paginatedData" :key="index" class="qr-card">
<div class="card-header">
<div class="card-title">
<i class="fas fa-user"></i>
{{ item.name }} - {{ item.id }}
</div>
<div class="card-actions">
<button class="action-btn" @click="downloadQRCode(item.id, item.name)">
<i class="fas fa-download"></i>
</button>
<button class="action-btn" @click="copyToClipboard(item.qrContent)">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="card-body">
<div class="qr-container">
<img :src="item.qrDataUrl" v-if="item.qrDataUrl" class="qr-image" alt="二维码">
<div v-else class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
</div>
</div>
<div class="data-preview">
<div v-for="(line, lineIndex) in item.qrContent.split('\n')" :key="lineIndex" class="data-line">
{{ line }}
</div>
</div>
</div>
</div>
</div>
<!-- 分页控件 -->
<div v-if="dataList.length > 0" class="pagination">
<button class="pagination-btn" @click="prevPage" :disabled="currentPage === 1">
<i class="fas fa-chevron-left"></i> 上一页
</button>
<div class="page-info">
第 {{ currentPage }} 页 / 共 {{ totalPages }} 页
</div>
<button class="pagination-btn" @click="nextPage" :disabled="currentPage === totalPages">
下一页 <i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</template>
<script>
import { ref, computed, watch, onMounted } from 'vue';
import QRCode from 'qrcode';
export default {
name: 'DynamicQRGenerator',
setup() {
// 状态管理
const loading = ref(false);
const generatedCount = ref(0);
// 配置参数
const qrSize = ref(200);
const colorDark = ref('#2c3e50');
const colorLight = ref('#ffffff');
const itemsPerPage = ref(6);
const currentPage = ref(1);
// 数据列表
const dataList = ref([]);
// 模拟从后端获取数据
const fetchData = async () => {
loading.value = true;
generatedCount.value = 0;
dataList.value = [];
// 模拟API请求延迟
await new Promise(resolve => setTimeout(resolve, 800));
// 模拟后端返回的数据结构
const mockData = [];
const names = ['张三', '李四', '王五', '赵六', '钱七', '孙八', '周九', '吴十'];
const roles = ['管理员', '编辑', '会员', 'VIP会员', '访客'];
const departments = ['技术部', '市场部', '销售部', '财务部', '人力资源'];
const statuses = ['活跃', '待激活', '已冻结', '已注销'];
// 生成模拟数据
for (let i = 1; i <= 24; i++) {
mockData.push({
id: `EMP-${1000 + i}`,
name: names[Math.floor(Math.random() * names.length)],
phone: `1${Math.floor(3000000000 + Math.random() * 1000000000)}`,
department: departments[Math.floor(Math.random() * departments.length)],
role: roles[Math.floor(Math.random() * roles.length)],
status: statuses[Math.floor(Math.random() * statuses.length)],
joinDate: new Date(Date.now() - Math.floor(Math.random() * 1000 * 60 * 60 * 24 * 365 * 3)).toISOString().split('T')[0],
qrDataUrl: '',
qrContent: ''
});
}
dataList.value = mockData;
generateAllQRCodes();
};
// 为每条数据生成二维码
const generateQRCode = async (item) => {
try {
// 格式化数据为多行文本
const text = `ID: ${item.id}\n姓名: ${item.name}\n电话: ${item.phone}\n部门: ${item.department}\n角色: ${item.role}\n状态: ${item.status}\n加入日期: ${item.joinDate}`;
// 生成二维码图片
const dataUrl = await QRCode.toDataURL(text, {
width: qrSize.value,
margin: 2,
color: {
dark: colorDark.value,
light: colorLight.value
},
errorCorrectionLevel: 'H'
});
// 更新数据项
item.qrDataUrl = dataUrl;
item.qrContent = text;
generatedCount.value++;
} catch (error) {
console.error(`生成二维码失败 (ID: ${item.id})`, error);
item.qrDataUrl = '';
item.qrContent = '生成二维码失败';
}
};
// 为所有数据生成二维码
const generateAllQRCodes = async () => {
for (const item of dataList.value) {
await generateQRCode(item);
}
loading.value = false;
};
// 当配置变化时重新生成二维码
const regenerateQRCodes = async () => {
if (dataList.value.length > 0) {
loading.value = true;
generatedCount.value = 0;
for (const item of dataList.value) {
await generateQRCode(item);
}
loading.value = false;
}
};
// 分页计算
const totalItems = computed(() => dataList.value.length);
const generatedQRCodes = computed(() => generatedCount.value);
const totalPages = computed(() => {
return Math.ceil(dataList.value.length / itemsPerPage.value);
});
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage.value;
const end = start + itemsPerPage.value;
return dataList.value.slice(start, end);
});
// 分页导航
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++;
}
};
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--;
}
};
// 下载单个二维码
const downloadQRCode = (id, name) => {
const item = dataList.value.find(item => item.id === id);
if (item && item.qrDataUrl) {
const link = document.createElement('a');
link.download = `${name}_${id}_二维码.png`;
link.href = item.qrDataUrl;
link.click();
}
};
// 批量下载所有二维码
const downloadAllQRCodes = async () => {
if (dataList.value.length === 0) return;
// 在实际应用中,这里可能需要使用JSZip等库创建ZIP包
// 这里使用简单方法逐个下载(可能会有浏览器限制)
for (const item of dataList.value) {
if (item.qrDataUrl) {
const link = document.createElement('a');
link.download = `${item.name}_${item.id}_二维码.png`;
link.href = item.qrDataUrl;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 添加延迟避免浏览器阻止
await new Promise(resolve => setTimeout(resolve, 300));
}
}
};
// 复制内容到剪贴板
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text)
.then(() => {
alert('数据已复制到剪贴板!');
})
.catch(err => {
console.error('复制失败:', err);
alert('复制失败,请手动复制');
});
};
// 初始加载数据
onMounted(fetchData);
// 监听配置变化
watch([qrSize, colorDark, colorLight], regenerateQRCodes);
watch(itemsPerPage, () => currentPage.value = 1);
return {
loading,
generatedCount,
qrSize,
colorDark,
colorLight,
itemsPerPage,
currentPage,
dataList,
paginatedData,
totalItems,
generatedQRCodes,
totalPages,
fetchData,
nextPage,
prevPage,
downloadQRCode,
downloadAllQRCodes,
copyToClipboard
};
}
};
</script>
<style scoped>
.qr-generator-container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
color: #343a40;
}
.header {
text-align: center;
padding: 30px 0;
margin-bottom: 20px;
}
.header h1 {
font-size: 2.2rem;
color: #2c3e50;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.header h1 i {
color: #3498db;
}
.subtitle {
color: #6c757d;
font-size: 1.1rem;
max-width: 700px;
margin: 0 auto;
}
.control-panel {
background: white;
border-radius: 12px;
padding: 25px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.08);
margin-bottom: 25px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 25px;
}
.config-group h3 {
color: #2c3e50;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #f1f2f6;
display: flex;
align-items: center;
gap: 10px;
}
.config-item {
margin-bottom: 20px;
}
.config-item label {
display: block;
margin-bottom: 8px;
color: #495057;
font-weight: 500;
}
.config-item input[type="range"] {
width: 100%;
}
.config-item select {
width: 100%;
padding: 10px 15px;
border: 1px solid #dee2e6;
border-radius: 8px;
font-size: 1rem;
}
.color-picker {
display: flex;
gap: 15px;
align-items: center;
}
.color-preview {
width: 40px;
height: 40px;
border-radius: 8px;
border: 1px solid #dee2e6;
}
.actions {
display: flex;
flex-direction: column;
gap: 15px;
justify-content: flex-end;
}
.btn {
padding: 12px 20px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: all 0.2s;
font-size: 1rem;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
}
.primary {
background: #3498db;
color: white;
}
.success {
background: #2ecc71;
color: white;
}
.stats {
display: flex;
justify-content: space-between;
gap: 20px;
margin-bottom: 25px;
}
.stat-card {
flex: 1;
background: white;
border-radius: 10px;
padding: 20px;
text-align: center;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.05);
}
.stat-value {
font-size: 2.2rem;
font-weight: 700;
color: #3498db;
margin-bottom: 5px;
}
.stat-label {
color: #6c757d;
font-size: 0.95rem;
}
.loading-state {
text-align: center;
padding: 50px;
background: white;
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.07);
margin-bottom: 25px;
}
.loading-state i {
font-size: 3rem;
color: #3498db;
margin-bottom: 20px;
animation: spin 1.5s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.empty-state {
text-align: center;
padding: 50px;
background: white;
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.07);
margin-bottom: 25px;
}
.empty-state i {
font-size: 4rem;
color: #ced4da;
margin-bottom: 20px;
}
.empty-state h3 {
color: #2c3e50;
margin-bottom: 15px;
}
.empty-state p {
color: #6c757d;
margin-bottom: 25px;
}
.qr-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 25px;
margin-bottom: 30px;
}
.qr-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.07);
transition: all 0.3s ease;
display: flex;
flex-direction: column;
}
.qr-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
}
.card-header {
background: linear-gradient(90deg, #3498db, #2c3e50);
color: white;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.card-actions {
display: flex;
gap: 10px;
}
.action-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.card-body {
padding: 20px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.qr-container {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
border-radius: 8px;
margin-bottom: 15px;
min-height: 220px;
}
.qr-image {
max-width: 90%;
max-height: 180px;
padding: 10px;
background: white;
border-radius: 6px;
}
.loading-spinner {
font-size: 2rem;
color: #3498db;
animation: spin 1.5s linear infinite;
}
.data-preview {
background: #f8fafc;
border-radius: 8px;
padding: 15px;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
flex-grow: 1;
}
.data-line {
padding: 4px 0;
white-space: pre-wrap;
word-break: break-word;
}
.pagination {
display: flex;
justify-content: center;
gap: 10px;
margin: 30px 0;
}
.pagination-btn {
background: #3498db;
color: white;
border: none;
padding: 10px 18px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.pagination-btn:disabled {
background: #adb5bd;
cursor: not-allowed;
}
.pagination-btn:hover:not(:disabled) {
background: #2980b9;
transform: translateY(-2px);
}
.page-info {
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
color: #495057;
margin: 0 15px;
min-width: 150px;
}
@media (max-width: 768px) {
.control-panel {
grid-template-columns: 1fr;
}
.stats {
flex-direction: column;
}
.qr-grid {
grid-template-columns: 1fr;
}
.pagination {
flex-wrap: wrap;
}
.page-info {
flex-basis: 100%;
margin: 10px 0;
}
}
</style>