feat: 库存 XLS 导入功能(后端接口 + 前端导入按钮)

This commit is contained in:
wangjia
2026-05-19 00:22:17 +08:00
parent b243ad885f
commit 7ac06a409d
4 changed files with 190 additions and 0 deletions
@@ -1,9 +1,13 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
import '../../models/inventory.dart';
import '../../core/auth/auth_state.dart';
import '../../providers/inventory_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
@@ -37,6 +41,55 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
});
}
Future<void> _importInventory(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['xls', 'xlsx'],
withData: true,
);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
final bytes = file.bytes;
if (bytes == null) return;
final token = ref.read(authStateProvider).user?.accessToken ?? '';
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
dio.options.headers['Authorization'] = 'Bearer $token';
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('导入中,请稍候...'), duration: Duration(seconds: 60)),
);
try {
final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(bytes, filename: file.name),
});
final resp = await dio.post('/import/inventory', data: formData);
final data = resp.data as Map<String, dynamic>;
final imported = data['imported'] ?? 0;
final skipped = data['skipped'] ?? 0;
final errors = (data['errors'] as List?)?.cast<String>() ?? [];
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('导入完成:$imported 条成功,$skipped 条跳过${errors.isNotEmpty ? '${errors.length} 条失败' : ''}'),
backgroundColor: errors.isEmpty ? Colors.green : AppTheme.accent,
duration: const Duration(seconds: 4),
));
ref.read(inventoryListProvider.notifier).reload();
} on DioException catch (e) {
final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ?? e.message ?? '未知错误';
if (!context.mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('导入失败:$msg'),
backgroundColor: AppTheme.danger,
));
}
}
@override
Widget build(BuildContext context) {
return PageScaffold(
@@ -144,6 +197,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
label: const Text('发起盘点'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => _importInventory(context, ref),
icon: const Icon(Icons.upload_file, size: 16),
label: const Text('导入库存'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: '库存查询',
@@ -1449,6 +1449,8 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
'格式:选项编号 | 选项名称 | 备注'),
_ImportSlot('商品规格', '/import/product-specs',
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
_ImportSlot('库存', '/import/inventory',
'格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'),
];
@override