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

@@ -30,6 +30,7 @@ class AppLocalizations {
String get editProgram => _localizedValues[locale.languageCode]?['editProgram'] ?? '编辑程序';
String get deleteProgram => _localizedValues[locale.languageCode]?['deleteProgram'] ?? '删除程序';
String get importProgram => _localizedValues[locale.languageCode]?['importProgram'] ?? '导入程序';
String get downloadTemplate => _localizedValues[locale.languageCode]?['downloadTemplate'] ?? '下载模板';
String get viewDetails => _localizedValues[locale.languageCode]?['viewDetails'] ?? '查看详情';
String get selectedProgram => _localizedValues[locale.languageCode]?['selectedProgram'] ?? '当前选中程序';
String get selectedProgramLabel => _localizedValues[locale.languageCode]?['selectedProgramLabel'] ?? '当前选中';
@@ -144,6 +145,7 @@ class AppLocalizations {
'editProgram': '编辑程序',
'deleteProgram': '删除程序',
'importProgram': '导入程序',
'downloadTemplate': '下载模板',
'viewDetails': '查看详情',
'selectedProgram': '当前选中程序',
'selectedProgramLabel': '当前选中',
@@ -245,6 +247,7 @@ class AppLocalizations {
'editProgram': 'Edit Program',
'deleteProgram': 'Delete Program',
'importProgram': 'Import Program',
'downloadTemplate': 'Download Template',
'viewDetails': 'View Details',
'selectedProgram': 'Selected Program',
'selectedProgramLabel': 'Selected',

View File

@@ -5,11 +5,13 @@ import 'package:file_picker/file_picker.dart';
import 'dart:io';
import '../../../core/localization/app_localizations.dart';
import '../../../core/theme/app_theme.dart';
import '../../../shared/services/toast_service.dart';
import '../../../shared/widgets/common_button.dart';
import '../models/program.dart';
import '../providers/programs_provider.dart';
import '../widgets/program_form_dialog.dart';
import '../services/program_import_service.dart';
import '../services/excel_import_service.dart';
import '../services/excel_template_service.dart';
/// 程序管理页面
class ProgramsPage extends ConsumerStatefulWidget {
@@ -64,6 +66,14 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
onPressed: () => _showAddDialog(context, ref),
),
const SizedBox(width: 12),
// 下载模板按钮
CommonButton(
text: l10n?.downloadTemplate ?? '下载模板',
icon: Icons.file_download,
type: ButtonType.secondary,
onPressed: () => _downloadTemplate(context),
),
const SizedBox(width: 12),
// 导入按钮
CommonButton(
text: l10n?.importProgram ?? '导入',
@@ -407,7 +417,7 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
// 选择文件
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
allowedExtensions: ['xlsx'],
allowMultiple: false,
);
@@ -417,38 +427,39 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
final file = result.files.first;
if (file.path == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('无法读取文件'),
backgroundColor: AppTheme.errorColor,
),
);
if (!context.mounted) return;
ToastService.showError(context, '无法读取文件');
return;
}
// 读取文件内容
final jsonContent = await File(file.path!).readAsString();
// 导入程序
final importedCount = await ProgramImportService.instance.importFromJson(jsonContent);
// 解析并写入数据库
final importedCount =
await ExcelImportService.instance.importFromExcel(File(file.path!));
// 刷新程序列表
ref.read(programsProvider.notifier).loadPrograms();
// 显示结果
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('成功导入 $importedCount 个程序'),
backgroundColor: importedCount > 0 ? AppTheme.successColor : AppTheme.warningColor,
),
);
if (!context.mounted) return;
if (importedCount > 0) {
ToastService.showSuccess(context, '成功导入 $importedCount 个程序');
} else {
ToastService.showWarning(context, '未导入新程序(编号可能已存在)');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('导入失败: ${e.toString()}'),
backgroundColor: AppTheme.errorColor,
),
);
if (!context.mounted) return;
ToastService.showError(context, '导入失败: ${e.toString()}');
}
}
/// 下载 Excel 模板
Future<void> _downloadTemplate(BuildContext context) async {
try {
final file = await ExcelTemplateService.instance.generateTemplate();
if (!context.mounted) return;
ToastService.showSuccess(context, '模板已保存: ${file.path}');
} catch (e) {
if (!context.mounted) return;
ToastService.showError(context, '生成模板失败: ${e.toString()}');
}
}

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;
}

View File

@@ -0,0 +1,134 @@
import 'dart:io';
import 'package:excel/excel.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';
/// 生成模板并保存到应用文档目录,返回文件对象
Future<File> generateTemplate() async {
final excel = Excel.createExcel();
final bytes = _buildTemplateBytes(excel);
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/program_template.xlsx');
await file.writeAsBytes(bytes, flush: true);
return file;
}
/// 构建模板字节流(可在测试中直接调用)
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.codestep_no 整数从 1 开始position 形如 A1/B2mixTime/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);
}
}

View File

@@ -1,126 +0,0 @@
import 'dart:convert';
import '../../programs/models/program.dart';
import '../../programs/models/step.dart';
import '../../programs/services/program_service.dart';
/// 程序导入服务
class ProgramImportService {
static final ProgramImportService instance = ProgramImportService._internal();
final ProgramService _programService = ProgramService.instance;
ProgramImportService._internal();
/// 从 JSON 字符串导入程序
/// 返回导入的程序数量
Future<int> importFromJson(String jsonContent) async {
final data = jsonDecode(jsonContent);
// 支持单个程序或程序数组
final List<dynamic> programsData;
if (data is List) {
programsData = data;
} else if (data is Map && data.containsKey('programs')) {
programsData = data['programs'] as List;
} else {
programsData = [data];
}
int importedCount = 0;
for (final programData in programsData) {
try {
// 验证必填字段
if (!_validateProgramData(programData)) {
continue;
}
// 检查编号是否已存在
final existingPrograms = await _programService.getAllPrograms();
final code = programData['code'] as String;
if (existingPrograms.any((p) => p.code == code)) {
// 编号已存在,跳过或使用新编号
continue;
}
// 创建程序
final program = Program(
code: code,
name: programData['name'] as String,
createdAt: programData['createdAt'] ?? DateTime.now().toString().split('.')[0],
status: programData['status'] ?? 1,
temperature: programData['temperature'] as int? ?? 50,
airflowTime: programData['airflowTime'] as int? ?? 60,
);
final programId = await _programService.addProgram(program);
// 导入步骤
final stepsData = programData['steps'] as List?;
if (stepsData != null) {
for (int i = 0; i < stepsData.length; i++) {
final stepData = stepsData[i];
final step = Step(
programId: programId,
stepNo: i + 1,
position: stepData['position'] as String? ?? 'A1',
name: stepData['name'] as String? ?? '步骤${i + 1}',
mixTime: stepData['mixTime'] as int? ?? 0,
magnetTime: stepData['magnetTime'] as int? ?? 0,
volume: stepData['volume'] as int? ?? 0,
blowTime: stepData['blowTime'] as int? ?? 0,
speed: stepData['speed'] as int? ?? 5,
);
await _programService.addStep(step);
}
}
importedCount++;
} catch (e) {
// 忽略单个程序导入错误
continue;
}
}
return importedCount;
}
/// 验证程序数据
bool _validateProgramData(Map<String, dynamic> data) {
return data.containsKey('code') &&
data.containsKey('name') &&
data['code'] is String &&
data['name'] is String;
}
/// 导出程序为 JSON
Future<String> exportToJson(List<int> programIds) async {
final programs = [];
for (final id in programIds) {
final program = await _programService.getProgramById(id);
if (program == null) continue;
final steps = await _programService.getStepsByProgramId(id);
programs.add({
'code': program.code,
'name': program.name,
'createdAt': program.createdAt,
'status': program.status,
'temperature': program.temperature,
'airflowTime': program.airflowTime,
'steps': steps.map((s) => {
'position': s.position,
'name': s.name,
'mixTime': s.mixTime,
'magnetTime': s.magnetTime,
'volume': s.volume,
'blowTime': s.blowTime,
'speed': s.speed,
}).toList(),
});
}
return jsonEncode({'programs': programs});
}
}