前言: 在移动应用开发中,经常需要将数据导出为Excel格式,特别是包含多个Sheet的数据报表。UniApp作为跨平台开发框架,在实现Excel文件下载功能时会遇到一些挑战。本文将详细介绍如何在UniApp安卓端实现下载包含多个Sheet的Excel文件。

技术选型: 考虑到UniApp安卓端的特性,我们采用前端生成Excel文件的方案,使用xlsx库实现多Sheet导出。

一、环境准备与配置

1. 安装必要依赖:xlsx库
npm install xlsx
2. manifest.json 配置
{
  "app-plus": {
    "distribute": {
      "android": {
        "permissions": [
          "<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\" />",
          "<uses-permission android:name=\"android.permission.READ_EXTERNAL_STORAGE\" />"
        ]
      }
    }
  }
}

二、核心实现步骤

1. 创建公共文件

1.1 引入方法与定义变量

// utils/exportToExcel.js
import * as XLSX from 'xlsx';
import {
	toast
} from './util.js';
import moment from 'moment';

// 默认列配置(展示部分)
const DEFAULT_COLUMNS = [{
		key: 'snCode',
		label: '目标ID',
		width: 20
	},
	{
		key: 'model',
		label: '机型',
		width: 10
	},
	...
];

1.2 导出主方法

/**
 * 导出Excel文件到本地
 * @param {Array} dataGroups 分组数据数组(一维数组或者二维数组)
 * @param {string} fileName 文件名(不含扩展名)
 * @returns {Promise<boolean>} 导出是否成功
 */
export const exportToExcel = async (dataGroups, fileName = '数据', isAutoOpen = false) => {
	try {
		// 验证数据
		if (!Array.isArray(dataGroups)) {
			return {
				status: false,
				msg: "下载数据格式错误"
			};
		}

		let validGroups = [];

		// 判断数据格式并处理
		if (dataGroups.length === 0) {
			return {
				status: false,
				msg: "无数据可下载"
			};
		} else if (Array.isArray(dataGroups[0])) {
			// 二维数组,直接使用
			validGroups = dataGroups.filter(group =>
				Array.isArray(group) && group.length
			);
		} else {
			// 一维数组,需要分组 
			validGroups = groupDataBySnCode(dataGroups).filter(group =>
				group.length
			);
		}

		// 确保每组数据都有snCode
		validGroups = validGroups.filter(group =>
			group.some(item => item.snCode)
		); 
		if (validGroups.length === 0) {
			return {
				status: false,
				msg: "无有效数据可下载"
			};
		}

		// 创建工作簿
		const wb = await createWorkbookWithSheets(validGroups);
		if (!wb.SheetNames || wb.SheetNames.length === 0) {
			return {
				status: false,
				msg: "创建工作簿失败,请检查数据格式"
			};
		}
		// 生成Excel二进制数据
		const wbout = XLSX.write(wb, {
			bookType: 'xlsx',
			type: 'array', // 输出Uint8Array
			bookSST: false,
			compression: true // 启用压缩,避免文件损坏
		});
		const fullFileName = `${fileName}.xlsx`;

		// 保存文件到本地
		const success = await saveFile(wbout, fullFileName,
			'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', isAutoOpen);
		if (success) {
			return {
				status: true,
				msg: `文件已保存至${fullFileName}`
			};
		} else {
			return {
				status: false,
				msg: "请检查权限设置"
			};
		}

	} catch (error) {
		console.error('下载Excel失败:', error);
		return {
			status: false,
			msg: error.message
		};
	}
};

1.3 数据类方法

// 处理数据,转换时间戳等字段
const processData = (data) => {
	if (!Array.isArray(data)) return [];

	return data.map(item => {
		const finnalHeight = item.height ? Number(item.height).toFixed(2) : 0;
		const finnalAltitude = item.altitude ? Number(item.altitude).toFixed(2) : 0;
		
		// 重要:表格列对应的字段都要return(这里展示部分)
		return {
			snCode: item.snCode || '',
			longitude: item.longitude ? Number(item.longitude).toFixed(6) : 0,
			latitude: item.latitude ? Number(item.latitude).toFixed(6) : 0,
			height: `${finnalHeight}米 [${finnalAltitude}米]`,
			...
		};
	});
};
/**
 * 按snCode分组数据
 * @param {Array} flatData 一维数组
 * @returns {Array} 分组后的数据数组(二维数组)
 */
export const groupDataBySnCode = (flatData) => {
	if (!Array.isArray(flatData)) {
		console.error("groupDataBySnCode: 输入不是数组");
		return [];
	} 
	const groups = {};

	flatData.forEach((item, index) => {
		if (!item || typeof item !== 'object') {
			console.warn(`${index}项数据无效`);
			return;
		}

		const snCode = item.snCode || '未知设备';
		if (!groups[snCode]) {
			groups[snCode] = [];
		}
		groups[snCode].push(item);
	});

	const result = Object.values(groups); 
	return result;
};

1.4 工具类方法

// 将时间戳转换为可读的日期时间字符串
const formatTimestamp = (timestamp) => {
	if (!timestamp || timestamp <= 0) return '-';
	return moment(timestamp).format('YYYY-MM-DD HH:mm:ss')
};
// 创建自定义的工作表
const createCustomSheet = (data, sheetName) => {
	try {
		if (!Array.isArray(data) || data.length === 0) {
			console.warn(`Sheet ${sheetName} 数据为空`);
			return null;
		}
		// 处理数据
		const processedData = processData(data);  
		// 提取表头
		const headers = DEFAULT_COLUMNS.map(col => col.label); 
		// 提取数据行
		const dataRows = processedData.map(item => {
			return DEFAULT_COLUMNS.map(col => {
				const value = item[col.key]; 
				// 处理空值
				if (value === undefined || value === null) {
					return '';
				} 
				// 返回原始值,让XLSX自动处理类型
				return value;
			});
		}); 
		// 合并表头和数据
		const wsData = [headers, ...dataRows]; 
		// 创建工作表
		const ws = XLSX.utils.aoa_to_sheet(wsData); 
		// 设置列宽
		if (!ws['!cols']) ws['!cols'] = [];
		ws['!cols'] = DEFAULT_COLUMNS.map(col => ({
			wch: col.width
		})); 
		return ws;
	} catch (error) {
		console.error(`创建Sheet ${sheetName} 失败:`, error);
		return null;
	}
};
// 保存文件到设备本地 
const saveFile = async (data, fileName, mimeType =
	'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', isAutoOpen) => {
	return new Promise((resolve) => {
		if (!plus) {
			console.error('plus 对象不存在,无法保存文件');
			resolve(false);
			return;
		} 
		// 移除所有非法文件名字符
		fileName = fileName.replace(/[\\/*?:[\]|<>]/g, '_'); 
		try {
			// 将 ArrayBuffer 转换为 Base64
			let base64Data;
			if (data instanceof ArrayBuffer) {
				console.log('数据是ArrayBuffer,转换为Base64');
				const bytes = new Uint8Array(data);
				let binary = '';
				const len = bytes.byteLength;
				for (let i = 0; i < len; i++) {
					binary += String.fromCharCode(bytes[i]);
				}
				base64Data = btoa(binary);
			} else if (data instanceof Uint8Array) {
				 console.log('数据是Uint8Array,转换为Base64');
				let binary = '';
				const len = data.length;
				for (let i = 0; i < len; i++) {
					binary += String.fromCharCode(data[i]);
				}
				base64Data = btoa(binary);
			} else {
				console.error('不支持的数据类型:', typeof data);
				toast('数据格式错误');
				resolve(false);
				return;
			}

			// 获取文档目录
			plus.io.requestFileSystem(plus.io.PRIVATE_DOC, (fs) => {
				fs.root.getFile(fileName, {
					create: true
				}, (fileEntry) => {
					fileEntry.createWriter((writer) => { 
						writer.onwriteend = () => { 
							// 是否自动打开文件
							if (isAutoOpen) {
								// 获取文件完整路径
								const filePath = fileEntry.toURL();
								try {
									// 打开文件
									openFile(filePath);
								} catch (openError) {
									console.error('文件打开失败:', openError);
									// toast('文件已保存,但打开失败: ' + openError
									// 	.message);
								}
							}
							resolve(true);
						};

						writer.onerror = (e) => {
							console.error('文件写入失败:', JSON.stringify(e));
							toast('文件保存失败: ' + (e.message || '未知错误'));
							resolve(false);
						};

						try {
							// 使用 writeAsBinary 方法写入 Base64 数据
							writer.writeAsBinary(base64Data);
						} catch (writeError) {
							console.error('文件写入异常:', writeError);
							toast('文件写入失败');
							resolve(false);
						}
					}, (e) => {
						console.error('创建writer失败:', e);
						toast('创建文件写入器失败: ' + (e.message || '未知错误'));
						resolve(false);
					});
				}, (e) => {
					console.error('获取文件失败:', e);
					toast('获取文件失败: ' + (e.message || '未知错误'));
					resolve(false);
				});
			}, (e) => {
				console.error('获取文件系统失败:', e);
				toast('获取存储系统失败: ' + (e.message || '未知错误'));
				resolve(false);
			});
		} catch (error) {
			console.error('保存文件异常:', error);
			toast('保存文件异常: ' + error.message);
			resolve(false);
		}
	});
}; 
/**
 * 打开文件
 * @param {string} filePath 文件路径
 * @param {string} mimeType 文件MIME类型
 */
const openFile = (filePath) => {
	if (!filePath) return;

	uni.openDocument({
		filePath,
		fileType: 'xlsx',
		success: (res) => {
			uni.hideLoading();
			console.log('打开文档成功', res);
		},
		fail: (err) => {
			uni.hideLoading();
			console.error('打开文档失败', err);
			uni.showToast({
				title: '无法打开文件,请使用WPS或Excel查看',
				icon: 'none',
				duration: 3000
			});
		}
	});
}
2. 主页面实现
// 先引入导出方法
import {
	exportToExcel
} from "@/utils/exportToExcel.js";
import {
	toast // 自定义提示方法,需替换成自己的
} from '@/utils/util.js'; 
import moment from 'moment';

data() {
	return {
		isDownloading: false, // 防止重复点击
	};
}, 
methods: {
	// 点击下载按钮执行
	async handleDownload() {
		if (this.isDownloading) return;
		// 获取数据:我这里通过SQLite获取的,替换成自己的数据即可
		const res = await this.$droneDBHelper.getTrajectoryByBatchIds(
			this.checkedIds
		); 
		const resArr = Array.from(res.values()); // 将 Map 转换为数组
		let data = [];
		if (Array.isArray(resArr) && resArr.length) {
			data = resArr;
		}
		const currentDate = moment().format("YYYY-MM-DD_HH-mm-ss");
		const fileName = `轨迹数据_${currentDate}`; // 自定义文件名
		this.isDownloading = true;
		loading("下载中...");
		try {
			const isAutoOpen = false;
			const res = await exportToExcel(data, fileName, isAutoOpen); // data: 数据,fileName:导出文件名,isAutoOpen:是否自动打开
			if (res.status) {
					toast("下载成功");
			} else {
					toast("下载失败:" + res.msg);
			}
		} catch (error) {
			console.error("下载报错:", error);
		} finally {
			hideLoading();
			this.isDownloading = false;
		}
	},
}

导出的数据格式如下(展示部分):展示的是二维数组,也可是一维数组

[
	[{
			"snCode": "111",
			"frequency": 5776.5,
			"model": "",
			"latitude": 11.517910008958317,
			"longitude": 101.28125619156881,
			"altitude": 10,
			"direct": 10,
			"speed": 333,
			"height": 33,
			"totalDistance": 120,
			"startDate": 1764937404513,
			"createDate": 1764937404514,
		},
		...
	],
	[{
			"snCode": "222",
			"frequency": 2437,
			"model": "",
			"latitude": 21.517910008958317,
			"longitude": 101.28125619156881,
			"altitude": 220,
			"direct": 20,
			"speed": 500,
			"height": 50,
			"totalDistance": 560,
			"startDate": 1764937409235,
			"createDate": 1764937409236,
		},
		...
	]
]
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐