Android 10+ 受 scoped storage 限制,getDownloadsDirectory() 返回的是 app-specific 目录 (/storage/emulated/0/Android/data/.../files/Download/), 而非用户可见的 /storage/emulated/0/Download/。 新增 MainActivity 端 MethodChannel com.xiarui.kuaishai2/downloads: - API 29+:MediaStore.Downloads 写入公共 Downloads,无需权限 - API <=28:直接写 /storage/emulated/0/Download/(需 WRITE_EXTERNAL_STORAGE) Dart 端 ExcelTemplateService 改用 MethodChannel,Android 平台返回 Download/<filename> 显示路径;其它平台保留 getDownloadsDirectory 行为。 返回值由 File 改为 String,调用方已同步更新。
162 lines
5.2 KiB
Dart
162 lines
5.2 KiB
Dart
import 'dart:io';
|
||
|
||
import 'package:excel/excel.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
|
||
/// Excel 模板服务
|
||
///
|
||
/// 生成 .xlsx 模板用于程序/步骤批量导入。
|
||
/// 模板包含两张表:
|
||
/// - Programs:程序基础信息
|
||
/// - Steps:步骤参数(通过 program_code 与 Programs 关联)
|
||
class ExcelTemplateService {
|
||
static final ExcelTemplateService instance = ExcelTemplateService._internal();
|
||
|
||
ExcelTemplateService._internal();
|
||
|
||
/// 工作表名称
|
||
static const String sheetPrograms = 'Programs';
|
||
static const String sheetSteps = 'Steps';
|
||
|
||
/// 模板文件名
|
||
static const String templateFilename = 'program_template.xlsx';
|
||
|
||
/// Android 端写入公共 Downloads 的 MethodChannel(对应 MainActivity.kt)
|
||
static const MethodChannel _downloadsChannel =
|
||
MethodChannel('com.xiarui.kuaishai2/downloads');
|
||
|
||
/// 生成模板并保存到下载目录,返回可显示的路径字符串
|
||
///
|
||
/// - Android:通过 MediaStore 写入公共 Downloads (`/storage/emulated/0/Download/`)
|
||
/// - 其它平台:使用 [getDownloadsDirectory],不可用时回退到应用文档目录
|
||
Future<String> generateTemplate() async {
|
||
final excel = Excel.createExcel();
|
||
final bytes = _buildTemplateBytes(excel);
|
||
|
||
if (Platform.isAndroid) {
|
||
final saved = await _downloadsChannel.invokeMethod<String>(
|
||
'saveToDownloads',
|
||
<String, dynamic>{
|
||
'filename': templateFilename,
|
||
'bytes': bytes,
|
||
},
|
||
);
|
||
if (saved == null) {
|
||
throw StateError('保存到 Downloads 失败:未返回路径');
|
||
}
|
||
return saved;
|
||
}
|
||
|
||
final dir =
|
||
await getDownloadsDirectory() ?? await getApplicationDocumentsDirectory();
|
||
final file = File('${dir.path}/$templateFilename');
|
||
await file.writeAsBytes(bytes, flush: true);
|
||
return file.path;
|
||
}
|
||
|
||
/// 构建模板字节流(可在测试中直接调用)
|
||
List<int> _buildTemplateBytes(Excel excel) {
|
||
// 默认创建的第一个 sheet 重命名
|
||
final defaultSheet = excel.getDefaultSheet();
|
||
if (defaultSheet != null && defaultSheet != sheetPrograms) {
|
||
excel.rename(defaultSheet, sheetPrograms);
|
||
}
|
||
|
||
_writeProgramsSheet(excel);
|
||
_writeStepsSheet(excel);
|
||
return excel.encode()!;
|
||
}
|
||
|
||
void _writeProgramsSheet(Excel excel) {
|
||
final sheet = excel[sheetPrograms];
|
||
final headerStyle = CellStyle(
|
||
bold: true,
|
||
backgroundColorHex: ExcelColor.fromHexString('FFD9E1F2'),
|
||
horizontalAlign: HorizontalAlign.Center,
|
||
);
|
||
|
||
// 表头
|
||
final headers = ['code', 'name', 'temperature', 'airflowTime', 'status'];
|
||
sheet.appendRow(headers.map((h) => TextCellValue(h)).toList());
|
||
for (var i = 0; i < headers.length; i++) {
|
||
sheet
|
||
.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0))
|
||
.cellStyle = headerStyle;
|
||
}
|
||
|
||
// 示例行
|
||
final sample1 = ['P001', '示例程序-标准流程', 50, 60, 1];
|
||
final sample2 = ['P002', '示例程序-快速流程', 45, 30, 1];
|
||
sheet.appendRow(sample1.map((v) => TextCellValue(v.toString())).toList());
|
||
sheet.appendRow(sample2.map((v) => TextCellValue(v.toString())).toList());
|
||
|
||
// 说明行(用前缀标记,避免被解析)
|
||
final note = '说明:code 必填且唯一;name 必填;temperature/airflowTime 整数;status:1启用 0停用';
|
||
sheet.appendRow([TextCellValue(note)]);
|
||
|
||
// 列宽
|
||
sheet.setColumnWidth(0, 14);
|
||
sheet.setColumnWidth(1, 26);
|
||
sheet.setColumnWidth(2, 14);
|
||
sheet.setColumnWidth(3, 14);
|
||
sheet.setColumnWidth(4, 10);
|
||
}
|
||
|
||
void _writeStepsSheet(Excel excel) {
|
||
final sheet = excel[sheetSteps];
|
||
final headerStyle = CellStyle(
|
||
bold: true,
|
||
backgroundColorHex: ExcelColor.fromHexString('FFD9E1F2'),
|
||
horizontalAlign: HorizontalAlign.Center,
|
||
);
|
||
|
||
final headers = [
|
||
'program_code',
|
||
'step_no',
|
||
'position',
|
||
'name',
|
||
'mixTime',
|
||
'magnetTime',
|
||
'volume',
|
||
'blowTime',
|
||
'speed',
|
||
];
|
||
sheet.appendRow(headers.map((h) => TextCellValue(h)).toList());
|
||
for (var i = 0; i < headers.length; i++) {
|
||
sheet
|
||
.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0))
|
||
.cellStyle = headerStyle;
|
||
}
|
||
|
||
// 示例行:P001 / P002 各两步
|
||
final samples = <List<Object>>[
|
||
['P001', 1, 'A1', '加样', 30, 0, 500, 0, 5],
|
||
['P001', 2, 'A2', '混合', 60, 10, 300, 30, 5],
|
||
['P002', 1, 'B1', '混合', 20, 5, 400, 0, 4],
|
||
];
|
||
for (final row in samples) {
|
||
sheet.appendRow(row.map((v) {
|
||
if (v is num) return IntCellValue(v.toInt());
|
||
return TextCellValue(v.toString());
|
||
}).toList());
|
||
}
|
||
|
||
// 说明行
|
||
final note =
|
||
'说明:program_code 对应 Programs.code;step_no 整数从 1 开始;position 形如 A1/B2;mixTime/magnetTime/volume/blowTime 单位秒或微升;speed 1-10';
|
||
sheet.appendRow([TextCellValue(note)]);
|
||
|
||
// 列宽
|
||
sheet.setColumnWidth(0, 14);
|
||
sheet.setColumnWidth(1, 10);
|
||
sheet.setColumnWidth(2, 12);
|
||
sheet.setColumnWidth(3, 14);
|
||
sheet.setColumnWidth(4, 10);
|
||
sheet.setColumnWidth(5, 12);
|
||
sheet.setColumnWidth(6, 10);
|
||
sheet.setColumnWidth(7, 10);
|
||
sheet.setColumnWidth(8, 8);
|
||
}
|
||
}
|