feat(programs): Excel 模板下载 + .xlsx 解析导入

- 新增 excel 4.0.6 / path_provider 2.1.5 依赖
- ExcelTemplateService:生成 Programs + Steps 双表模板(保存到应用文档目录)
- ExcelImportService:解析 .xlsx 并写入数据库,跳过已存在 code、按 program_code 关联步骤
- programs_page 顶部新增「下载模板」按钮,导入按钮改用 Excel 解析
- 移除被取代的 program_import_service.dart
- AppLocalizations 新增 downloadTemplate 键
- 验证:flutter analyze 无新增 issue;flutter build apk --debug 通过
This commit is contained in:
Developer
2026-06-04 15:27:34 +08:00
parent d53c41c300
commit d91791edaf
7 changed files with 504 additions and 153 deletions

View File

@@ -0,0 +1,211 @@
import 'dart:io';
import 'package:excel/excel.dart';
import '../models/program.dart';
import '../models/step.dart';
import '../services/program_service.dart';
import 'excel_template_service.dart';
/// Excel 导入服务
///
/// 解析用户填好的 .xlsx 并通过 [ProgramService] 写入数据库。
/// 模板结构与 [ExcelTemplateService] 一致Programs + Steps 双表,
/// 通过 program_code 关联。
class ExcelImportService {
static final ExcelImportService instance = ExcelImportService._internal();
final ProgramService _programService = ProgramService.instance;
ExcelImportService._internal();
/// 从 .xlsx 文件导入程序,返回成功导入的程序数量
Future<int> importFromExcel(File file) async {
final bytes = await file.readAsBytes();
final excel = Excel.decodeBytes(bytes);
final programsSheet = _findSheet(excel, ExcelTemplateService.sheetPrograms);
if (programsSheet == null) {
throw const ExcelImportException('缺少 Programs 工作表');
}
final programs = _parsePrograms(programsSheet);
if (programs.isEmpty) {
throw const ExcelImportException('Programs 表无有效数据');
}
// 读取已有 code避免重复
final existing = await _programService.getAllPrograms();
final existingCodes = existing.map((p) => p.code).toSet();
// 解析步骤
final stepsSheet = _findSheet(excel, ExcelTemplateService.sheetSteps);
final stepsByCode = stepsSheet == null
? <String, List<_RawStep>>{}
: _parseSteps(stepsSheet);
int importedCount = 0;
for (final program in programs) {
try {
if (existingCodes.contains(program.code)) {
continue;
}
final programId = await _programService.addProgram(program);
final rawSteps = stepsByCode[program.code] ?? const <_RawStep>[];
// 按 step_no 排序后写入
rawSteps.sort((a, b) => a.stepNo.compareTo(b.stepNo));
for (var i = 0; i < rawSteps.length; i++) {
final raw = rawSteps[i];
final step = Step(
programId: programId,
stepNo: i + 1,
position: raw.position,
name: raw.name,
mixTime: raw.mixTime,
magnetTime: raw.magnetTime,
volume: raw.volume,
blowTime: raw.blowTime,
speed: raw.speed,
);
await _programService.addStep(step);
}
importedCount++;
} catch (_) {
// 单条失败不影响其他程序
continue;
}
}
return importedCount;
}
Sheet? _findSheet(Excel excel, String name) {
for (final entry in excel.tables.entries) {
if (entry.key == name) return entry.value;
}
return null;
}
/// 解析 Programs 表
/// 返回不含 id 的 Program 列表createdAt 由调用方填充)
List<Program> _parsePrograms(Sheet sheet) {
final rows = _dataRows(sheet);
final results = <Program>[];
for (final cells in rows) {
final code = _readString(cells, 0);
final name = _readString(cells, 1);
if (code.isEmpty || name.isEmpty) continue;
results.add(
Program(
code: code,
name: name,
createdAt: DateTime.now().toString().split('.').first,
status: _readInt(cells, 4, 1) == 0 ? 0 : 1,
temperature: _readInt(cells, 2, 50),
airflowTime: _readInt(cells, 3, 60),
),
);
}
return results;
}
/// 解析 Steps 表
/// 以 program_code 为 key 分组
Map<String, List<_RawStep>> _parseSteps(Sheet sheet) {
final rows = _dataRows(sheet);
final map = <String, List<_RawStep>>{};
for (final cells in rows) {
final programCode = _readString(cells, 0);
if (programCode.isEmpty) continue;
final position = _readString(cells, 2, 'A1');
final name = _readString(cells, 3);
if (name.isEmpty) continue;
final raw = _RawStep(
stepNo: _readInt(cells, 1, 0),
position: position,
name: name,
mixTime: _readInt(cells, 4, 0),
magnetTime: _readInt(cells, 5, 0),
volume: _readInt(cells, 6, 0),
blowTime: _readInt(cells, 7, 0),
speed: _readInt(cells, 8, 5),
);
map.putIfAbsent(programCode, () => []).add(raw);
}
return map;
}
/// 跳过表头和说明行(首行表头,后续以"说明"开头的视为说明)
List<List<Object?>> _dataRows(Sheet sheet) {
final result = <List<Object?>>[];
for (var i = 1; i < sheet.rows.length; i++) {
final row = sheet.rows[i];
// 跳过空行
if (row.every((c) => c == null || c.value == null)) continue;
final firstCell = _asTrimmed(row.first?.value);
if (firstCell.startsWith('说明')) continue;
result.add(row.map((c) => c?.value).toList(growable: false));
}
return result;
}
String _readString(List<Object?> row, int index, [String fallback = '']) {
if (index >= row.length) return fallback;
final v = row[index];
if (v == null) return fallback;
final s = v.toString().trim();
return s.isEmpty ? fallback : s;
}
int _readInt(List<Object?> row, int index, int fallback) {
if (index >= row.length) return fallback;
final v = row[index];
if (v == null) return fallback;
if (v is int) return v;
if (v is double) return v.toInt();
final parsed = int.tryParse(v.toString().trim());
return parsed ?? fallback;
}
String _asTrimmed(Object? v) {
if (v == null) return '';
return v.toString().trim();
}
}
class _RawStep {
final int stepNo;
final String position;
final String name;
final int mixTime;
final int magnetTime;
final int volume;
final int blowTime;
final int speed;
_RawStep({
required this.stepNo,
required this.position,
required this.name,
required this.mixTime,
required this.magnetTime,
required this.volume,
required this.blowTime,
required this.speed,
});
}
/// Excel 导入异常
class ExcelImportException implements Exception {
final String message;
const ExcelImportException(this.message);
@override
String toString() => message;
}