nginx: - 新增 /api/v1/import/ 专属 location,proxy_read_timeout 延长至 300s 后端: - ImportInventory 预加载商品/仓库/库存(3 次 bulk query 替代 N+1) - 批量写 inventory_log(CreateInBatches 替代逐行 Create) 前端: - settings 导入行:显示"上传 XX%"进度条和"导入数据(Ns)"计时 - Dio 超时设置为 send=120s / receive=300s Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -99,42 +99,94 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
final file = result.files.first;
|
||||
final bytes = file.bytes;
|
||||
if (bytes == null) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final token = ref.read(authStateProvider).user?.accessToken ?? '';
|
||||
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
sendTimeout: const Duration(seconds: 120),
|
||||
receiveTimeout: const Duration(seconds: 300),
|
||||
));
|
||||
dio.options.headers['Authorization'] = 'Bearer $token';
|
||||
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('导入中,请稍候...'), duration: Duration(seconds: 60)),
|
||||
final stateNotifier = ValueNotifier<_ImportState>(
|
||||
const _ImportState(stage: _ImportStage.uploading, uploadPercent: 0),
|
||||
);
|
||||
|
||||
BuildContext? dialogCtx;
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) {
|
||||
dialogCtx = ctx;
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: _ImportProgressDialog(stateNotifier: stateNotifier),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Timer? processingTimer;
|
||||
bool uploadDone = false;
|
||||
int elapsed = 0;
|
||||
|
||||
void closeDialog() {
|
||||
processingTimer?.cancel();
|
||||
if (dialogCtx != null && dialogCtx!.mounted) {
|
||||
Navigator.of(dialogCtx!).pop();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
final resp = await dio.post(
|
||||
'/import/inventory',
|
||||
data: formData,
|
||||
onSendProgress: (sent, total) {
|
||||
if (total <= 0) return;
|
||||
if (sent >= total && !uploadDone) {
|
||||
uploadDone = true;
|
||||
elapsed = 0;
|
||||
stateNotifier.value = const _ImportState(
|
||||
stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: 0,
|
||||
);
|
||||
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
elapsed++;
|
||||
stateNotifier.value = _ImportState(
|
||||
stage: _ImportStage.processing, uploadPercent: 100, processingSeconds: elapsed,
|
||||
);
|
||||
});
|
||||
} else if (!uploadDone) {
|
||||
final pct = (sent / total * 100).round().clamp(0, 99);
|
||||
stateNotifier.value = _ImportState(stage: _ImportStage.uploading, uploadPercent: pct);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
processingTimer?.cancel();
|
||||
final data = resp.data as Map<String, dynamic>;
|
||||
stateNotifier.value = _ImportState(
|
||||
stage: _ImportStage.done,
|
||||
uploadPercent: 100,
|
||||
imported: data['imported'] ?? 0,
|
||||
updated: data['updated'] ?? 0,
|
||||
skipped: data['skipped'] ?? 0,
|
||||
errors: (data['errors'] as List?)?.cast<String>() ?? [],
|
||||
);
|
||||
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
closeDialog();
|
||||
if (context.mounted) 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,
|
||||
));
|
||||
processingTimer?.cancel();
|
||||
final msg = (e.response?.data is Map ? e.response!.data['error'] : null)
|
||||
?? e.message ?? '未知错误';
|
||||
stateNotifier.value = _ImportState(
|
||||
stage: _ImportStage.error, uploadPercent: 0, errorMsg: msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,3 +917,149 @@ class _DirectionBadge extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 导入进度对话框 ──────────────────────────────────────────
|
||||
|
||||
enum _ImportStage { uploading, processing, done, error }
|
||||
|
||||
class _ImportState {
|
||||
final _ImportStage stage;
|
||||
final int uploadPercent;
|
||||
final int processingSeconds;
|
||||
final int imported;
|
||||
final int updated;
|
||||
final int skipped;
|
||||
final List<String> errors;
|
||||
final String? errorMsg;
|
||||
|
||||
const _ImportState({
|
||||
required this.stage,
|
||||
required this.uploadPercent,
|
||||
this.processingSeconds = 0,
|
||||
this.imported = 0,
|
||||
this.updated = 0,
|
||||
this.skipped = 0,
|
||||
this.errors = const [],
|
||||
this.errorMsg,
|
||||
});
|
||||
}
|
||||
|
||||
class _ImportProgressDialog extends StatelessWidget {
|
||||
final ValueNotifier<_ImportState> stateNotifier;
|
||||
const _ImportProgressDialog({required this.stateNotifier});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('导入库存'),
|
||||
content: ValueListenableBuilder<_ImportState>(
|
||||
valueListenable: stateNotifier,
|
||||
builder: (_, state, __) => SizedBox(
|
||||
width: 320,
|
||||
child: _buildContent(context, state),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
ValueListenableBuilder<_ImportState>(
|
||||
valueListenable: stateNotifier,
|
||||
builder: (ctx, state, __) {
|
||||
if (state.stage == _ImportStage.done || state.stage == _ImportStage.error) {
|
||||
return TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('关闭'),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, _ImportState state) {
|
||||
switch (state.stage) {
|
||||
case _ImportStage.uploading:
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('上传文件...', style: TextStyle(fontSize: 14)),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: state.uploadPercent / 100,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text('${state.uploadPercent}%',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||
],
|
||||
);
|
||||
|
||||
case _ImportStage.processing:
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
SizedBox(
|
||||
width: 16, height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'导入数据中...${state.processingSeconds > 0 ? "(${state.processingSeconds}秒)" : ""}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
case _ImportStage.done:
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.check_circle, color: AppTheme.success, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text('导入成功', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
Text('新增:${state.imported} 条', style: const TextStyle(fontSize: 13)),
|
||||
if (state.updated > 0)
|
||||
Text('更新:${state.updated} 条', style: const TextStyle(fontSize: 13)),
|
||||
if (state.skipped > 0)
|
||||
Text('跳过:${state.skipped} 条',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
||||
if (state.errors.isNotEmpty)
|
||||
Text('失败:${state.errors.length} 条',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
],
|
||||
);
|
||||
|
||||
case _ImportStage.error:
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: AppTheme.danger, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'导入失败:${state.errorMsg}',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import '../../core/utils/dialog_util.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
@@ -1524,10 +1525,22 @@ class _ImportSlot {
|
||||
int total = 0;
|
||||
int imported = 0;
|
||||
int skipped = 0;
|
||||
// 进度
|
||||
int uploadPercent = 0;
|
||||
bool isProcessing = false;
|
||||
int processingSeconds = 0;
|
||||
|
||||
_ImportSlot(this.title, this.endpoint, this.hint);
|
||||
|
||||
bool get hasResult => success != null;
|
||||
|
||||
void resetProgress() {
|
||||
uploadPercent = 0;
|
||||
isProcessing = false;
|
||||
processingSeconds = 0;
|
||||
success = null;
|
||||
error = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _BatchImportWidget extends ConsumerStatefulWidget {
|
||||
@@ -1657,14 +1670,15 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
for (final s in _slots) {
|
||||
if (s.file != null) {
|
||||
s.success = null;
|
||||
s.error = null;
|
||||
}
|
||||
if (s.file != null) s.resetProgress();
|
||||
}
|
||||
});
|
||||
|
||||
final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl));
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
sendTimeout: const Duration(seconds: 120),
|
||||
receiveTimeout: const Duration(seconds: 300),
|
||||
));
|
||||
dio.options.headers['Authorization'] = 'Bearer $token';
|
||||
|
||||
for (final slot in _slots) {
|
||||
@@ -1674,29 +1688,56 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
||||
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
|
||||
continue;
|
||||
}
|
||||
|
||||
Timer? processingTimer;
|
||||
bool uploadDone = false;
|
||||
|
||||
try {
|
||||
final formData = FormData.fromMap({
|
||||
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
|
||||
});
|
||||
final resp = await dio.post(slot.endpoint, data: formData);
|
||||
final resp = await dio.post(
|
||||
slot.endpoint,
|
||||
data: formData,
|
||||
onSendProgress: (sent, total) {
|
||||
if (total <= 0 || !mounted) return;
|
||||
if (sent >= total && !uploadDone) {
|
||||
uploadDone = true;
|
||||
setState(() { slot.isProcessing = true; slot.processingSeconds = 0; });
|
||||
processingTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => slot.processingSeconds++);
|
||||
});
|
||||
} else if (!uploadDone) {
|
||||
final pct = (sent / total * 100).round().clamp(0, 99);
|
||||
if (mounted) setState(() => slot.uploadPercent = pct);
|
||||
}
|
||||
},
|
||||
);
|
||||
processingTimer?.cancel();
|
||||
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
|
||||
if (mounted) setState(() {
|
||||
slot.success = true;
|
||||
slot.total = (data['total'] ?? data['imported'] ?? 0) as int;
|
||||
slot.imported = (data['imported'] ?? 0) as int;
|
||||
slot.skipped = (data['skipped'] ?? 0) as int;
|
||||
slot.isProcessing = false;
|
||||
});
|
||||
} on DioException catch (e) {
|
||||
processingTimer?.cancel();
|
||||
final raw = e.response?.data;
|
||||
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
|
||||
if (mounted) setState(() {
|
||||
slot.success = false;
|
||||
slot.error = msg.toString();
|
||||
slot.isProcessing = false;
|
||||
});
|
||||
} catch (e) {
|
||||
processingTimer?.cancel();
|
||||
if (mounted) setState(() {
|
||||
slot.success = false;
|
||||
slot.error = e.toString();
|
||||
slot.isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2047,10 +2088,42 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
||||
);
|
||||
}
|
||||
} else if (_loading && slot.file != null) {
|
||||
statusWidget = const SizedBox(
|
||||
width: 14, height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
if (slot.isProcessing) {
|
||||
statusWidget = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 12, height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'导入数据${slot.processingSeconds > 0 ? "(${slot.processingSeconds}秒)" : ""}',
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
statusWidget = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: LinearProgressIndicator(
|
||||
value: slot.uploadPercent / 100,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primary),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'上传 ${slot.uploadPercent}%',
|
||||
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
|
||||
Reference in New Issue
Block a user