feat(ad): 添加广告位备份恢复功能

- 在搜索组件中增加备份和恢复按钮
- 实现广告位数据导出为Excel文件功能
- 创建Import组件实现Excel文件导入恢复功能
- 修复表格row-key从cmsAdId改为adId
- 添加导出加载状态控制避免重复操作
- 实现按当前搜索条件导出指定数据
- 支持通过Excel文件批量恢复广告位数据
- 导入时自动识别更新或新增操作
- 添加文件格式和大小验证
- 实现导入进度提示和错误处理
This commit is contained in:
2026-01-21 16:16:14 +08:00
parent 6c01151af1
commit 4f33d67d98
3 changed files with 379 additions and 3 deletions

View File

@@ -0,0 +1,219 @@
<!-- 广告位导入备份弹窗 -->
<template>
<ele-modal
:width="520"
:footer="null"
title="导入备份"
:visible="visible"
@update:visible="updateVisible"
>
<a-spin :spinning="loading">
<a-upload-dragger
accept=".xls,.xlsx"
:show-upload-list="false"
:customRequest="doUpload"
style="padding: 24px 0; margin-bottom: 16px"
>
<p class="ant-upload-drag-icon">
<cloud-upload-outlined />
</p>
<p class="ant-upload-hint">将文件拖到此处或点击上传</p>
</a-upload-dragger>
</a-spin>
<div class="ele-text-center">
<span>只能上传xlsxlsx文件恢复时有广告ID则更新没有则新增</span>
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { CloudUploadOutlined } from '@ant-design/icons-vue';
import { read, utils } from 'xlsx';
import { addCmsAd, updateCmsAd } from '@/api/cms/cmsAd';
import type { CmsAd } from '@/api/cms/cmsAd/model';
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
defineProps<{
// 是否打开弹窗
visible: boolean;
}>();
// 导入请求状态
const loading = ref(false);
const COL = {
adId: '广告ID',
type: '类型',
code: '唯一标识',
categoryId: '栏目ID',
categoryName: '栏目名称',
name: '广告位名称',
width: '宽',
height: '高',
style: '样式',
images: '图片数据',
path: '链接',
sortNumber: '排序号',
comments: '备注',
status: '状态',
lang: '语言',
tenantId: '租户ID',
merchantId: '商户ID'
} as const;
const parseNumber = (val: any): number | undefined => {
if (val === '' || val === null || val === undefined) {
return undefined;
}
const n = Number(val);
return Number.isFinite(n) ? n : undefined;
};
const parseString = (val: any): string | undefined => {
if (val === '' || val === null || val === undefined) {
return undefined;
}
return String(val);
};
const normalizeImages = (val: any): string => {
const raw = parseString(val);
if (!raw) {
return '';
}
// 备份文件中图片数据优先使用 JSON如果是 url则补成编辑器保存时的数组格式
try {
const parsed = JSON.parse(raw);
return JSON.stringify(parsed ?? []);
} catch (_e) {
return JSON.stringify([
{ uid: '', url: raw, status: 'done', title: '', path: '' }
]);
}
};
/* 上传 */
const doUpload = async ({ file }: any) => {
if (
![
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
].includes(file.type)
) {
message.error('只能选择 excel 文件');
return false;
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
return false;
}
loading.value = true;
try {
const buffer = await file.arrayBuffer();
const wb = read(buffer, { type: 'array' });
const sheetName = wb.SheetNames?.[0];
if (!sheetName) {
throw new Error('文件中未找到工作表');
}
const sheet = wb.Sheets[sheetName];
const rows = utils.sheet_to_json<Record<string, any>>(sheet, {
defval: ''
});
if (!rows?.length) {
throw new Error('文件内容为空');
}
if (!(COL.code in rows[0]) && !(COL.name in rows[0])) {
throw new Error('文件格式不匹配,请使用“备份”导出的文件进行恢复');
}
Modal.confirm({
title: '提示',
content: `确定要恢复 ${rows.length} 条广告位吗有广告ID则更新没有则新增`,
maskClosable: true,
onOk: async () => {
const hide = message.loading('恢复中..', 0);
let ok = 0;
let fail = 0;
const errors: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i] ?? {};
const payload: CmsAd = {
adId: parseNumber(row[COL.adId]),
type: parseNumber(row[COL.type]),
code: parseString(row[COL.code]),
categoryId: parseNumber(row[COL.categoryId]),
categoryName: parseString(row[COL.categoryName]),
name: parseString(row[COL.name]),
width: parseString(row[COL.width]),
height: parseString(row[COL.height]),
style: parseString(row[COL.style]),
images: normalizeImages(row[COL.images]),
path: parseString(row[COL.path]),
sortNumber: parseNumber(row[COL.sortNumber]),
comments: parseString(row[COL.comments]),
status: parseNumber(row[COL.status]),
lang: parseString(row[COL.lang]),
tenantId: parseNumber(row[COL.tenantId]),
merchantId: parseNumber(row[COL.merchantId])
};
try {
const saveOrUpdate = payload.adId ? updateCmsAd : addCmsAd;
await saveOrUpdate(payload);
ok++;
} catch (e: any) {
fail++;
errors.push(
`${i + 1}${payload.code ? `(${payload.code})` : ''}: ${
e?.message || '失败'
}`
);
}
}
hide();
loading.value = false;
if (fail > 0) {
message.warning(`恢复完成:成功${ok},失败${fail}`);
// 避免弹窗过长,只展示前几条
Modal.error({
title: '部分恢复失败',
content: createVNode(
'pre',
{
style:
'white-space: pre-wrap; max-height: 320px; overflow: auto; margin: 0;'
},
errors.slice(0, 8).join('\n')
)
});
} else {
message.success(`恢复完成:成功${ok}`);
}
updateVisible(false);
emit('done');
},
onCancel: () => {
loading.value = false;
}
});
} catch (e: any) {
loading.value = false;
message.error(e?.message || '解析失败,请重试');
}
return false;
};
/* 更新 visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
</script>

View File

@@ -7,6 +7,10 @@
</template>
<span>添加</span>
</a-button>
<a-button type="dashed" :disabled="exportLoading" @click="backup"
>备份</a-button
>
<a-button type="dashed" @click="restore">恢复</a-button>
<a-tree-select
allow-clear
:tree-data="navigationList"
@@ -41,6 +45,7 @@
// 选中的角色
selection?: [];
navigationList?: CmsNavigation[];
exportLoading?: boolean;
}>(),
{}
);
@@ -50,6 +55,8 @@
(e: 'add'): void;
(e: 'remove'): void;
(e: 'batchMove'): void;
(e: 'backup'): void;
(e: 'restore'): void;
}>();
// 表单数据
@@ -64,6 +71,16 @@
emit('add');
};
// 备份
const backup = () => {
emit('backup');
};
// 恢复
const restore = () => {
emit('restore');
};
// 按分类查询
const onCategoryId = (id: number) => {
where.categoryId = id;

View File

@@ -3,7 +3,7 @@
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="cmsAdId"
row-key="adId"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
@@ -15,6 +15,9 @@
@search="reload"
:selection="selection"
:navigationList="navigationList"
:exportLoading="exportLoading"
@backup="handleExport"
@restore="openImport"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
@@ -41,7 +44,7 @@
<div :class="`item ${record.style}`">
<template
v-if="record.type != 4"
v-for="item in record.imageList"
v-for="(item, index) in record.imageList"
:key="index"
>
<a-image :src="item.url" :width="80" />
@@ -78,6 +81,9 @@
:navigationList="navigationList"
@done="reload"
/>
<!-- 导入备份弹窗 -->
<Import v-model:visible="showImport" @done="reload" />
</a-page-header>
</template>
@@ -85,6 +91,7 @@
import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import { utils, writeFile } from 'xlsx';
import type { EleProTable } from 'ele-admin-pro';
import { toTreeData } from 'ele-admin-pro';
import { useI18n } from 'vue-i18n';
@@ -94,11 +101,18 @@
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import CmsAdEdit from './components/cmsAdEdit.vue';
import { pageCmsAd, removeCmsAd, removeBatchCmsAd } from '@/api/cms/cmsAd';
import Import from './components/Import.vue';
import {
listCmsAd,
pageCmsAd,
removeBatchCmsAd,
removeCmsAd
} from '@/api/cms/cmsAd';
import type { CmsAd, CmsAdParam } from '@/api/cms/cmsAd/model';
import { CmsNavigation } from '@/api/cms/cmsNavigation/model';
import { listCmsNavigation } from '@/api/cms/cmsNavigation';
import { getPageTitle } from '@/utils/common';
import { getTenantId } from '@/utils/domain';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
@@ -112,10 +126,16 @@
const showEdit = ref(false);
// 是否显示批量移动弹窗
const showMove = ref(false);
// 是否显示导入备份弹窗
const showImport = ref(false);
// 栏目数据
const navigationList = ref<CmsNavigation[]>();
// 加载状态
const loading = ref(true);
// 导出状态
const exportLoading = ref(false);
// 记录最新搜索条件,供备份导出使用
const lastWhere = ref<CmsAdParam>({});
// 表格数据源
const datasource: DatasourceFunction = ({
@@ -189,6 +209,9 @@
/* 搜索 */
const reload = (where?: CmsAdParam) => {
if (where) {
lastWhere.value = { ...where };
}
selection.value = [];
tableRef?.value?.reload({ where: where });
};
@@ -204,6 +227,123 @@
showMove.value = true;
};
/* 打开导入弹窗 */
const openImport = () => {
showImport.value = true;
};
/* 备份导出 */
const handleExport = async () => {
if (exportLoading.value) {
return;
}
exportLoading.value = true;
message.loading('正在准备导出数据...', 0);
const array: (string | number)[][] = [
[
'广告ID',
'类型',
'唯一标识',
'栏目ID',
'栏目名称',
'广告位名称',
'宽',
'高',
'样式',
'图片数据',
'链接',
'排序号',
'备注',
'状态',
'语言',
'租户ID',
'商户ID'
]
];
try {
const where: CmsAdParam = {
...(lastWhere.value ?? {}),
lang: locale.value || undefined
};
const list = await listCmsAd(where);
if (!list || list.length === 0) {
message.destroy();
message.warning('没有数据可以导出');
exportLoading.value = false;
return;
}
list.forEach((d: CmsAd) => {
const images = Array.isArray(d.imageList)
? JSON.stringify(d.imageList)
: typeof d.images === 'string'
? d.images
: JSON.stringify(d.images ?? []);
array.push([
`${d.adId || ''}`,
`${d.type ?? ''}`,
`${d.code || ''}`,
`${d.categoryId ?? ''}`,
`${d.categoryName || ''}`,
`${d.name || ''}`,
`${d.width || ''}`,
`${d.height || ''}`,
`${d.style || ''}`,
`${images || ''}`,
`${d.path || ''}`,
`${d.sortNumber ?? ''}`,
`${d.comments || ''}`,
`${d.status ?? ''}`,
`${d.lang || ''}`,
`${d.tenantId ?? ''}`,
`${d.merchantId ?? ''}`
]);
});
const sheetName = `bak_cms_ad_${getTenantId()}`;
const workbook = {
SheetNames: [sheetName],
Sheets: {}
} as any;
const sheet = utils.aoa_to_sheet(array);
workbook.Sheets[sheetName] = sheet;
sheet['!cols'] = [
{ wch: 10 }, // 广告ID
{ wch: 8 }, // 类型
{ wch: 24 }, // 唯一标识
{ wch: 10 }, // 栏目ID
{ wch: 18 }, // 栏目名称
{ wch: 24 }, // 广告位名称
{ wch: 10 }, // 宽
{ wch: 10 }, // 高
{ wch: 20 }, // 样式
{ wch: 60 }, // 图片数据(JSON)
{ wch: 30 }, // 链接
{ wch: 10 }, // 排序号
{ wch: 24 }, // 备注
{ wch: 8 }, // 状态
{ wch: 10 }, // 语言
{ wch: 10 }, // 租户ID
{ wch: 10 } // 商户ID
];
message.destroy();
message.loading('正在生成Excel文件...', 0);
setTimeout(() => {
writeFile(workbook, `${sheetName}.xlsx`);
exportLoading.value = false;
message.destroy();
message.success(`成功导出 ${list.length} 条记录`);
}, 600);
} catch (e: any) {
exportLoading.value = false;
message.destroy();
message.error(e?.message || '导出失败,请重试');
}
};
/* 删除单个 */
const remove = (row: CmsAd) => {
const hide = message.loading('请求中..', 0);