feat(shopGift): 添加礼品卡二维码批量导出功能

- 新增礼品卡二维码批量导出功能,支持导出为 Word 文档或 HTML 文件
- 优化搜索组件,增加关键字搜索功能
- 修改表格列配置,将 ID列改为用户 ID 列
- 优化审核状态变化逻辑,自动
This commit is contained in:
2025-08-18 00:59:26 +08:00
parent 35b5b35048
commit 9aaaec8c26
6 changed files with 458 additions and 92 deletions

View File

@@ -52,5 +52,6 @@ export interface ShopGift {
*/ */
export interface ShopGiftParam extends PageParam { export interface ShopGiftParam extends PageParam {
id?: number; id?: number;
code?: string;
keywords?: string; keywords?: string;
} }

View File

@@ -28,6 +28,7 @@
<a-input-number <a-input-number
:min="1" :min="1"
placeholder="请输入用户ID" placeholder="请输入用户ID"
:disabled="isUpdate"
v-model:value="form.userId" v-model:value="form.userId"
style="width: 100%" style="width: 100%"
/> />
@@ -38,6 +39,7 @@
<a-input <a-input
placeholder="请输入真实姓名" placeholder="请输入真实姓名"
v-model:value="form.realName" v-model:value="form.realName"
:disabled="isUpdate"
/> />
</a-form-item> </a-form-item>
</a-col> </a-col>
@@ -48,6 +50,7 @@
<a-form-item label="手机号码" name="mobile"> <a-form-item label="手机号码" name="mobile">
<a-input <a-input
placeholder="请输入手机号码" placeholder="请输入手机号码"
:disabled="isUpdate"
v-model:value="form.mobile" v-model:value="form.mobile"
/> />
</a-form-item> </a-form-item>
@@ -57,6 +60,7 @@
<a-input-number <a-input-number
:min="1" :min="1"
placeholder="请输入推荐人用户ID" placeholder="请输入推荐人用户ID"
:disabled="isUpdate"
v-model:value="form.refereeId" v-model:value="form.refereeId"
style="width: 100%" style="width: 100%"
/> />
@@ -64,38 +68,6 @@
</a-col> </a-col>
</a-row> </a-row>
<!-- 申请设置 -->
<a-divider orientation="left">
<span style="color: #1890ff; font-weight: 600;">申请设置</span>
</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="申请方式" name="applyType">
<a-radio-group v-model:value="form.applyType">
<a-radio :value="10">
<a-tag color="orange">需要审核</a-tag>
<span style="margin-left: 8px;">后台人工审核</span>
</a-radio>
<a-radio :value="20">
<a-tag color="green">免审核</a-tag>
<span style="margin-left: 8px;">自动通过</span>
</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="申请时间" name="applyTime">
<a-date-picker
v-model:value="form.applyTime"
show-time
placeholder="请选择申请时间"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 审核信息 --> <!-- 审核信息 -->
<a-divider orientation="left"> <a-divider orientation="left">
<span style="color: #1890ff; font-weight: 600;">审核信息</span> <span style="color: #1890ff; font-weight: 600;">审核信息</span>
@@ -104,7 +76,7 @@
<a-row :gutter="16"> <a-row :gutter="16">
<a-col :span="12"> <a-col :span="12">
<a-form-item label="审核状态" name="applyStatus"> <a-form-item label="审核状态" name="applyStatus">
<a-select v-model:value="form.applyStatus" placeholder="请选择审核状态"> <a-select v-model:value="form.applyStatus" placeholder="请选择审核状态" @change="handleStatusChange">
<a-select-option :value="10"> <a-select-option :value="10">
<a-tag color="processing">待审核</a-tag> <a-tag color="processing">待审核</a-tag>
<span style="margin-left: 8px;">等待审核</span> <span style="margin-left: 8px;">等待审核</span>
@@ -121,10 +93,11 @@
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="12">
<a-form-item label="审核时间" name="auditTime" v-if="form.applyStatus && form.applyStatus !== 10"> <a-form-item label="审核时间" name="auditTime" v-if="form.applyStatus === 20 || form.applyStatus === 30">
<a-date-picker <a-date-picker
v-model:value="form.auditTime" v-model:value="form.auditTime"
show-time show-time
format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择审核时间" placeholder="请选择审核时间"
style="width: 100%" style="width: 100%"
/> />
@@ -132,15 +105,20 @@
</a-col> </a-col>
</a-row> </a-row>
<a-form-item label="驳回原因" name="rejectReason" v-if="form.applyStatus === 30"> <a-row :gutter="16" v-if="form.applyStatus === 30">
<a-col :span="24">
<a-form-item label="驳回原因" name="rejectReason">
<a-textarea <a-textarea
v-model:value="form.rejectReason" v-model:value="form.rejectReason"
placeholder="请输入驳回原因" placeholder="请输入驳回原因"
style="width: 100%"
:rows="3" :rows="3"
:maxlength="200" :maxlength="200"
show-count show-count
/> />
</a-form-item> </a-form-item>
</a-col>
</a-row>
</a-form> </a-form>
</ele-modal> </ele-modal>
</template> </template>
@@ -257,14 +235,17 @@
], ],
rejectReason: [ rejectReason: [
{ {
validator: (rule: any, value: any) => { required: true,
if (form.applyStatus === 30 && !value) { message: '驳回时必须填写驳回原因',
return Promise.reject('驳回时必须填写驳回原因');
}
return Promise.resolve();
},
trigger: 'blur' trigger: 'blur'
} }
],
auditTime: [
{
required: true,
message: '审核时请选择审核时间',
trigger: 'change'
}
] ]
}); });
@@ -272,25 +253,71 @@
const { resetFields } = useForm(form, rules); const { resetFields } = useForm(form, rules);
/* 处理审核状态变化 */
const handleStatusChange = (value: number) => {
// 当状态改为审核通过或驳回时,自动设置审核时间为当前时间
if ((value === 20 || value === 30) && !form.auditTime) {
form.auditTime = dayjs();
}
// 当状态改为待审核时,清空审核时间和驳回原因
if (value === 10) {
form.auditTime = undefined;
form.rejectReason = '';
}
};
/* 保存编辑 */ /* 保存编辑 */
const save = () => { const save = () => {
if (!formRef.value) { if (!formRef.value) {
return; return;
} }
// 动态验证规则
const validateFields: string[] = ['userId', 'realName', 'mobile', 'applyStatus'];
// 如果是驳回状态,需要验证驳回原因
if (form.applyStatus === 30) {
validateFields.push('rejectReason');
}
// 如果是审核通过或驳回状态,需要验证审核时间
if (form.applyStatus === 20 || form.applyStatus === 30) {
validateFields.push('auditTime');
}
formRef.value formRef.value
.validate() .validate(validateFields)
.then(() => { .then(() => {
loading.value = true; loading.value = true;
const formData = { const formData = {
...form ...form
}; };
// 处理时间字段转换 // 处理时间字段转换 - 转换为ISO字符串格式
if (formData.applyTime && dayjs.isDayjs(formData.applyTime)) { if (formData.applyTime) {
formData.applyTime = formData.applyTime.valueOf(); if (dayjs.isDayjs(formData.applyTime)) {
formData.applyTime = formData.applyTime.format('YYYY-MM-DD HH:mm:ss');
} else if (typeof formData.applyTime === 'number') {
formData.applyTime = dayjs(formData.applyTime).format('YYYY-MM-DD HH:mm:ss');
} }
if (formData.auditTime && dayjs.isDayjs(formData.auditTime)) { }
formData.auditTime = formData.auditTime.valueOf();
if (formData.auditTime) {
if (dayjs.isDayjs(formData.auditTime)) {
formData.auditTime = formData.auditTime.format('YYYY-MM-DD HH:mm:ss');
} else if (typeof formData.auditTime === 'number') {
formData.auditTime = dayjs(formData.auditTime).format('YYYY-MM-DD HH:mm:ss');
}
}
// 当审核状态为通过或驳回时,确保有审核时间
if ((formData.applyStatus === 20 || formData.applyStatus === 30) && !formData.auditTime) {
formData.auditTime = dayjs().format('YYYY-MM-DD HH:mm:ss');
}
// 当状态为待审核时,清空审核时间
if (formData.applyStatus === 10) {
formData.auditTime = undefined;
} }
const saveOrUpdate = isUpdate.value ? updateShopDealerApply : addShopDealerApply; const saveOrUpdate = isUpdate.value ? updateShopDealerApply : addShopDealerApply;
@@ -315,7 +342,7 @@
if (visible) { if (visible) {
if (props.data) { if (props.data) {
assignObject(form, props.data); assignObject(form, props.data);
// 处理时间字段 // 处理时间字段 - 确保转换为dayjs对象
if (props.data.applyTime) { if (props.data.applyTime) {
form.applyTime = dayjs(props.data.applyTime); form.applyTime = dayjs(props.data.applyTime);
} }

View File

@@ -117,9 +117,9 @@
// 表格列配置 // 表格列配置
const columns = ref<ColumnItem[]>([ const columns = ref<ColumnItem[]>([
{ {
title: 'ID', title: '用户ID',
dataIndex: 'applyId', dataIndex: 'userId',
key: 'applyId', key: 'userId',
align: 'center', align: 'center',
width: 80, width: 80,
fixed: 'left' fixed: 'left'

View File

@@ -112,9 +112,9 @@ const datasource: DatasourceFunction = ({
// 表格列配置 // 表格列配置
const columns = ref<ColumnItem[]>([ const columns = ref<ColumnItem[]>([
{ {
title: 'ID', title: '用户ID',
dataIndex: 'id', dataIndex: 'userId',
key: 'id', key: 'userId',
align: 'center', align: 'center',
width: 80, width: 80,
fixed: 'left' fixed: 'left'

View File

@@ -22,17 +22,6 @@
<a-form-item label="礼品卡" name="name"> <a-form-item label="礼品卡" name="name">
<a-input allow-clear placeholder="请输入礼品卡名称" v-model:value="form.name"/> <a-input allow-clear placeholder="请输入礼品卡名称" v-model:value="form.name"/>
</a-form-item> </a-form-item>
<a-form-item label="关联商品" name="goodsId">
<a-select placeholder="请选择关联商品" v-model:value="form.goodsId">
<a-select-option
v-for="item in goodsList"
:key="item.goodsId"
:value="item.goodsId"
>
{{ item.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="关联商品" name="goodsId"> <a-form-item label="关联商品" name="goodsId">
<a-select <a-select
v-model:value="form.goodsId" v-model:value="form.goodsId"

View File

@@ -13,20 +13,46 @@
</template> </template>
<span>批量生成</span> <span>批量生成</span>
</a-button> </a-button>
<a-button class="ele-btn-icon" @click="exportData"> <a-input-search
<span>导出</span> allow-clear
</a-button> v-model:value="where.keywords"
placeholder="名称|秘钥|用户ID"
style="width: 240px"
@search="reload"
@pressEnter="reload"
/>
<a-button
type="text"
:icon="h(QrcodeOutlined)"
@click="handleExport"
:loading="exportLoading"
>导出二维码</a-button
>
<MakeCard v-model:visible="showMultiAdd" @done="done"></MakeCard> <MakeCard v-model:visible="showMultiAdd" @done="done"></MakeCard>
</a-space> </a-space>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { PlusOutlined } from '@ant-design/icons-vue'; import { PlusOutlined, QrcodeOutlined } from '@ant-design/icons-vue';
import { watch, ref } from 'vue'; import { watch, ref, h } from 'vue';
import {ShopGift, ShopGiftParam} from "@/api/shop/shopGift/model"; import {ShopGift, ShopGiftParam} from "@/api/shop/shopGift/model";
import MakeCard from "@/views/shop/shopGift/components/makeCard.vue"; import MakeCard from "@/views/shop/shopGift/components/makeCard.vue";
import {exportShopGift} from "@/api/shop/shopGift"; import {listShopGift} from "@/api/shop/shopGift";
import {message} from "ant-design-vue"; import {message} from "ant-design-vue";
import {
Document,
Packer,
Paragraph,
ImageRun,
AlignmentType,
Table,
TableRow,
TableCell,
WidthType
} from 'docx';
import { saveAs } from 'file-saver';
import QRCode from 'qrcode';
import useSearch from "@/utils/use-search";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -44,9 +70,18 @@
(e: 'done'): void; (e: 'done'): void;
}>(); }>();
// 表单数据
const { where } = useSearch<ShopGiftParam>({
keywords: ''
});
// 新增 // 新增
const add = () => { // const add = () => {
emit('add'); // emit('add');
// };
const reload = () => {
emit('search', {...where});
}; };
const done = () => { const done = () => {
@@ -54,22 +89,336 @@
}; };
const showMultiAdd = ref(false) const showMultiAdd = ref(false)
const exportLoading = ref(false);
const openMultiAdd = () => { const openMultiAdd = () => {
showMultiAdd.value = true showMultiAdd.value = true
}; };
const exportData = async () => { // 批量导出二维码到Word文档
const hide = message.loading('请求中..', 0); const handleExport = async () => {
const ids = [] try {
if (props.selection && props.selection.length) { exportLoading.value = true;
props.selection.forEach(d => { message.loading('正在生成二维码文档,请稍候...', 0);
ids.push(d.id)
// 获取所有礼品卡数据
let giftList: ShopGift[] = [];
if (props.selection && props.selection.length > 0) {
// 如果有选中的数据,只导出选中的
giftList = props.selection;
} else {
// 如果没有选中,导出所有数据
giftList = await listShopGift();
}
if (!giftList || giftList.length === 0) {
message.error('没有礼品卡数据可导出');
return;
}
// 生成二维码图片
const qrCodeImages: { dataUrl: string; giftInfo: ShopGift }[] = [];
for (const gift of giftList) {
try {
// 生成二维码使用礼品卡code作为内容
const qrCodeDataUrl = await QRCode.toDataURL(String(gift.code), {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
qrCodeImages.push({ dataUrl: qrCodeDataUrl, giftInfo: gift });
} catch (error) {
console.error(`生成礼品卡 ${gift.code} 的二维码失败:`, error);
}
}
if (qrCodeImages.length === 0) {
message.error('二维码生成失败');
return;
}
// 尝试创建Word文档如果失败则使用HTML方式
try {
await createWordDocument(qrCodeImages);
message.destroy();
message.success(`成功导出 ${qrCodeImages.length} 个礼品卡二维码`);
} catch (docError) {
console.warn('Word文档生成失败使用HTML方式:', docError);
createHtmlDocument(qrCodeImages);
message.destroy();
message.success(
`成功生成 ${qrCodeImages.length} 个礼品卡二维码HTML格式可直接打印`
);
}
} catch (error) {
console.error('导出失败:', error);
message.destroy();
message.error('导出失败,请重试');
} finally {
exportLoading.value = false;
}
};
// 创建Word文档
const createWordDocument = async (
qrCodeImages: { dataUrl: string; giftInfo: ShopGift }[]
) => {
const children: (Paragraph | Table)[] = [];
// 添加标题
children.push(
new Paragraph({
text: '礼品卡二维码清单',
alignment: AlignmentType.CENTER,
spacing: { after: 400 }
}) })
);
// 每行放置3个二维码保持适当间距
const itemsPerRow = 3;
const rows = Math.ceil(qrCodeImages.length / itemsPerRow);
for (let row = 0; row < rows; row++) {
const startIndex = row * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, qrCodeImages.length);
const rowItems = qrCodeImages.slice(startIndex, endIndex);
// 创建表格行来放置二维码
const qrCodeCells: TableCell[] = [];
const infoCells: TableCell[] = [];
for (let i = 0; i < itemsPerRow; i++) {
if (i < rowItems.length) {
const item = rowItems[i];
// 将DataURL转换为Buffer
const base64Data = item.dataUrl.split(',')[1];
const binaryData = atob(base64Data);
const bytes = new Uint8Array(binaryData.length);
for (let j = 0; j < binaryData.length; j++) {
bytes[j] = binaryData.charCodeAt(j);
} }
const res = await exportShopGift(ids);
window.open(res.url); qrCodeCells.push(
hide(); new TableCell({
children: [
new Paragraph({
children: [
// @ts-ignore
new ImageRun({
data: bytes,
transformation: {
width: 150,
height: 150
} }
})
],
alignment: AlignmentType.CENTER
})
],
width: { size: 33, type: WidthType.PERCENTAGE }
})
);
infoCells.push(
new TableCell({
children: [
new Paragraph({
text: `${item.giftInfo.code || '未设置'}`,
alignment: AlignmentType.CENTER
}),
new Paragraph({
text: `${item.giftInfo.name || ''}`,
alignment: AlignmentType.CENTER
})
],
width: { size: 33, type: WidthType.PERCENTAGE }
})
);
} else {
// 空单元格
qrCodeCells.push(
new TableCell({
children: [new Paragraph({ text: '' })],
width: { size: 33, type: WidthType.PERCENTAGE }
})
);
infoCells.push(
new TableCell({
children: [new Paragraph({ text: '' })],
width: { size: 33, type: WidthType.PERCENTAGE }
})
);
}
}
// 添加表格
children.push(
new Table({
rows: [
new TableRow({
children: qrCodeCells
}),
new TableRow({
children: infoCells
})
],
width: { size: 100, type: WidthType.PERCENTAGE }
})
);
// 添加行间距
children.push(
new Paragraph({
text: '',
spacing: { after: 400 }
})
);
}
// 创建文档
const doc = new Document({
sections: [
{
properties: {
page: {
size: {
orientation: 'portrait',
width: 11906, // A4宽度 (210mm)
height: 16838 // A4高度 (297mm)
},
margin: {
top: 1134, // 2cm
right: 1134, // 2cm
bottom: 1134, // 2cm
left: 1134 // 2cm
}
}
},
children
}
]
});
// 生成并下载文档
try {
const buffer = await Packer.toBlob(doc);
const fileName = `礼品卡二维码清单_${new Date()
.toISOString()
.slice(0, 10)}.docx`;
saveAs(buffer, fileName);
} catch (error) {
console.error('文档生成失败:', error);
// 如果Packer.toBlob失败尝试使用toBuffer
const buffer = await Packer.toBuffer(doc);
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
});
const fileName = `礼品卡二维码清单_${new Date()
.toISOString()
.slice(0, 10)}.docx`;
saveAs(blob, fileName);
}
};
// 创建HTML文档备用方案
const createHtmlDocument = (
qrCodeImages: { dataUrl: string; giftInfo: ShopGift }[]
) => {
const itemsPerRow = 3;
let htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>礼品卡二维码清单</title>
<style>
@page {
size: A4;
margin: 2cm;
}
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
.title {
text-align: center;
font-size: 24px;
font-weight: bold;
margin-bottom: 30px;
}
.qr-grid {
display: grid;
grid-template-columns: repeat(${itemsPerRow}, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.qr-item {
text-align: center;
page-break-inside: avoid;
}
.qr-code {
width: 150px;
height: 150px;
margin: 0 auto 10px;
}
.qr-info {
font-size: 12px;
line-height: 1.4;
}
@media print {
.no-print { display: none; }
}
</style>
</head>
<body>
<div class="title">礼品卡二维码清单</div>
<div class="qr-grid">
`;
qrCodeImages.forEach((item) => {
htmlContent += `
<div class="qr-item">
<img src="${item.dataUrl}" alt="QR Code" class="qr-code">
<div class="qr-info">
<div>礼品卡编号: ${item.giftInfo.code || '未设置'}</div>
<div>礼品卡名称: ${item.giftInfo.name || ''}</div>
<div>ID: ${item.giftInfo.id}</div>
</div>
</div>
`;
});
htmlContent += `
</div>
<div class="no-print" style="text-align: center; margin-top: 30px;">
<button onclick="window.print()" style="padding: 10px 20px; font-size: 16px;">打印文档</button>
<button onclick="window.close()" style="padding: 10px 20px; font-size: 16px; margin-left: 10px;">关闭</button>
</div>
</body>
</html>
`;
// 在新窗口中打开HTML文档
const newWindow = window.open('', '_blank');
if (newWindow) {
newWindow.document.write(htmlContent);
newWindow.document.close();
} else {
// 如果弹窗被阻止,创建下载链接
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
const fileName = `礼品卡二维码清单_${new Date()
.toISOString()
.slice(0, 10)}.html`;
saveAs(blob, fileName);
}
};
watch( watch(
() => props.selection, () => props.selection,