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:
@@ -519,23 +519,47 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var res importResult
|
var res importResult
|
||||||
|
|
||||||
// 仓库缓存,只查找不创建
|
// 预加载仓库(1 次查询)
|
||||||
warehouseCache := map[string]*uint64{}
|
var warehouses []model.Warehouse
|
||||||
|
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses)
|
||||||
|
warehouseByName := make(map[string]uint64, len(warehouses))
|
||||||
|
for _, wh := range warehouses {
|
||||||
|
warehouseByName[wh.Name] = wh.ID
|
||||||
|
}
|
||||||
findWarehouse := func(name string) *uint64 {
|
findWarehouse := func(name string) *uint64 {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if idPtr, ok := warehouseCache[name]; ok {
|
if id, ok := warehouseByName[name]; ok {
|
||||||
return idPtr
|
return &id
|
||||||
}
|
}
|
||||||
var wh model.Warehouse
|
return nil
|
||||||
if h.db.Where("shop_id = ? AND name = ? AND deleted_at IS NULL", shopID, name).First(&wh).Error != nil {
|
}
|
||||||
warehouseCache[name] = nil
|
|
||||||
return nil
|
// 预加载商品(1 次查询)
|
||||||
|
var allProducts []model.Product
|
||||||
|
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&allProducts)
|
||||||
|
productByCode := make(map[string]*model.Product, len(allProducts))
|
||||||
|
productByNSS := make(map[string]*model.Product, len(allProducts))
|
||||||
|
for i := range allProducts {
|
||||||
|
p := &allProducts[i]
|
||||||
|
if p.Code != "" {
|
||||||
|
productByCode[p.Code] = p
|
||||||
}
|
}
|
||||||
id := wh.ID
|
productByNSS[p.Name+"|"+p.Series+"|"+p.Spec] = p
|
||||||
warehouseCache[name] = &id
|
}
|
||||||
return &id
|
|
||||||
|
// 预加载已有导入库存(1 次查询)
|
||||||
|
var allInvs []model.Inventory
|
||||||
|
h.db.Where("shop_id = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL", shopID).Find(&allInvs)
|
||||||
|
invByKey := make(map[string]*model.Inventory, len(allInvs))
|
||||||
|
for i := range allInvs {
|
||||||
|
inv := &allInvs[i]
|
||||||
|
whID := uint64(0)
|
||||||
|
if inv.WarehouseID != nil {
|
||||||
|
whID = *inv.WarehouseID
|
||||||
|
}
|
||||||
|
invByKey[fmt.Sprintf("%s|%d", inv.ProductCode, whID)] = inv
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic column detection from header row
|
// Dynamic column detection from header row
|
||||||
@@ -561,6 +585,8 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var logsToCreate []model.InventoryLog
|
||||||
|
|
||||||
for i, row := range rows[1:] {
|
for i, row := range rows[1:] {
|
||||||
productName := cell(row, 1)
|
productName := cell(row, 1)
|
||||||
if productName == "" {
|
if productName == "" {
|
||||||
@@ -586,20 +612,30 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
price, _ := strconv.ParseFloat(priceStr, 64)
|
price, _ := strconv.ParseFloat(priceStr, 64)
|
||||||
|
|
||||||
// 只查找商品,不强制创建
|
// 从缓存查商品,找不到才创建
|
||||||
var prod model.Product
|
var prod *model.Product
|
||||||
if h.db.Where("shop_id = ? AND deleted_at IS NULL AND (code = ? OR (name = ? AND series = ? AND spec = ?))",
|
if productCode != "" {
|
||||||
shopID, productCode, productName, series, spec).First(&prod).Error != nil {
|
prod = productByCode[productCode]
|
||||||
// 若找不到则创建
|
}
|
||||||
|
if prod == nil {
|
||||||
|
prod = productByNSS[productName+"|"+series+"|"+spec]
|
||||||
|
}
|
||||||
|
if prod == nil {
|
||||||
newProd, createErr := findOrCreateProductFn(h.db, shopID, productCode, productName, series, spec)
|
newProd, createErr := findOrCreateProductFn(h.db, shopID, productCode, productName, series, spec)
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error()))
|
res.errors = append(res.errors, fmt.Sprintf("行%d: 商品创建失败: %s", i+2, createErr.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
prod = newProd
|
allProducts = append(allProducts, newProd)
|
||||||
|
prod = &allProducts[len(allProducts)-1]
|
||||||
|
if prod.Code != "" {
|
||||||
|
productByCode[prod.Code] = prod
|
||||||
|
}
|
||||||
|
productByNSS[prod.Name+"|"+prod.Series+"|"+prod.Spec] = prod
|
||||||
}
|
}
|
||||||
if unit != "" && prod.Unit == "" {
|
if unit != "" && prod.Unit == "" {
|
||||||
h.db.Model(&prod).Update("unit", unit)
|
h.db.Model(prod).Update("unit", unit)
|
||||||
|
prod.Unit = unit
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析生产日期
|
// 解析生产日期
|
||||||
@@ -609,7 +645,6 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
productionDate = &d
|
productionDate = &d
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找仓库(只查,不创建)
|
|
||||||
whIDPtr := findWarehouse(warehouseName)
|
whIDPtr := findWarehouse(warehouseName)
|
||||||
|
|
||||||
var unitPricePtr *float64
|
var unitPricePtr *float64
|
||||||
@@ -617,30 +652,25 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
unitPricePtr = &price
|
unitPricePtr = &price
|
||||||
}
|
}
|
||||||
|
|
||||||
productIDCopy := prod.ID
|
// 从缓存查库存记录
|
||||||
|
whIDVal := uint64(0)
|
||||||
// Upsert:按商品编号 + 仓库查找已有导入记录,存在则更新,不存在则新建
|
|
||||||
var existing model.Inventory
|
|
||||||
q := h.db.Where("shop_id = ? AND product_code = ? AND stock_in_item_id IS NULL AND deleted_at IS NULL",
|
|
||||||
shopID, prod.Code)
|
|
||||||
if whIDPtr != nil {
|
if whIDPtr != nil {
|
||||||
q = q.Where("warehouse_id = ?", *whIDPtr)
|
whIDVal = *whIDPtr
|
||||||
} else {
|
|
||||||
q = q.Where("warehouse_id IS NULL")
|
|
||||||
}
|
}
|
||||||
found := q.First(&existing).Error == nil
|
invKey := fmt.Sprintf("%s|%d", prod.Code, whIDVal)
|
||||||
|
existing := invByKey[invKey]
|
||||||
|
|
||||||
if found {
|
if existing != nil {
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"quantity": qty,
|
"quantity": qty,
|
||||||
"product_name": prod.Name,
|
"product_name": prod.Name,
|
||||||
"series": prod.Series,
|
"series": prod.Series,
|
||||||
"spec": prod.Spec,
|
"spec": prod.Spec,
|
||||||
"unit": prod.Unit,
|
"unit": prod.Unit,
|
||||||
"warehouse_name": warehouseName,
|
"warehouse_name": warehouseName,
|
||||||
"supplier_name": supplierName,
|
"supplier_name": supplierName,
|
||||||
"remark": remark,
|
"remark": remark,
|
||||||
"deleted_at": nil,
|
"deleted_at": nil,
|
||||||
}
|
}
|
||||||
if unitPricePtr != nil {
|
if unitPricePtr != nil {
|
||||||
updates["unit_price"] = *unitPricePtr
|
updates["unit_price"] = *unitPricePtr
|
||||||
@@ -651,11 +681,18 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
if batchNo != "" {
|
if batchNo != "" {
|
||||||
updates["batch_no"] = batchNo
|
updates["batch_no"] = batchNo
|
||||||
}
|
}
|
||||||
if err := h.db.Model(&existing).Updates(updates).Error; err != nil {
|
if err := h.db.Model(existing).Updates(updates).Error; err != nil {
|
||||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
|
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存更新失败: %s", i+2, err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
logsToCreate = append(logsToCreate, model.InventoryLog{
|
||||||
|
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
|
||||||
|
Direction: "in", Quantity: qty, QtyBefore: existing.Quantity, QtyAfter: qty,
|
||||||
|
RefType: "import", RefID: 0,
|
||||||
|
})
|
||||||
|
res.updated++
|
||||||
} else {
|
} else {
|
||||||
|
productIDCopy := prod.ID
|
||||||
inv := model.Inventory{
|
inv := model.Inventory{
|
||||||
ShopID: shopID,
|
ShopID: shopID,
|
||||||
WarehouseID: whIDPtr,
|
WarehouseID: whIDPtr,
|
||||||
@@ -678,32 +715,19 @@ func (h *ImportHandler) ImportInventory(c *gin.Context) {
|
|||||||
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
|
res.errors = append(res.errors, fmt.Sprintf("行%d: 库存写入失败: %s", i+2, err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
invByKey[invKey] = &inv
|
||||||
|
logsToCreate = append(logsToCreate, model.InventoryLog{
|
||||||
// 写流水
|
ShopID: shopID, WarehouseID: whIDVal, ProductID: prod.ID,
|
||||||
warehouseID := uint64(0)
|
Direction: "in", Quantity: qty, QtyBefore: 0, QtyAfter: qty,
|
||||||
if whIDPtr != nil {
|
RefType: "import", RefID: 0,
|
||||||
warehouseID = *whIDPtr
|
})
|
||||||
}
|
|
||||||
qtyBefore := 0.0
|
|
||||||
if found {
|
|
||||||
qtyBefore = existing.Quantity
|
|
||||||
res.updated++
|
|
||||||
} else {
|
|
||||||
res.imported++
|
res.imported++
|
||||||
}
|
}
|
||||||
log := model.InventoryLog{
|
}
|
||||||
ShopID: shopID,
|
|
||||||
WarehouseID: warehouseID,
|
// 批量写流水
|
||||||
ProductID: prod.ID,
|
if len(logsToCreate) > 0 {
|
||||||
Direction: "in",
|
h.db.CreateInBatches(&logsToCreate, 100)
|
||||||
Quantity: qty,
|
|
||||||
QtyBefore: qtyBefore,
|
|
||||||
QtyAfter: qty,
|
|
||||||
RefType: "import",
|
|
||||||
RefID: 0,
|
|
||||||
}
|
|
||||||
h.db.Create(&log)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
|||||||
@@ -99,42 +99,94 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
|||||||
final file = result.files.first;
|
final file = result.files.first;
|
||||||
final bytes = file.bytes;
|
final bytes = file.bytes;
|
||||||
if (bytes == null) return;
|
if (bytes == null) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final token = ref.read(authStateProvider).user?.accessToken ?? '';
|
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';
|
dio.options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
|
||||||
if (!context.mounted) return;
|
final stateNotifier = ValueNotifier<_ImportState>(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
const _ImportState(stage: _ImportStage.uploading, uploadPercent: 0),
|
||||||
const SnackBar(content: Text('导入中,请稍候...'), duration: Duration(seconds: 60)),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'file': MultipartFile.fromBytes(bytes, filename: file.name),
|
'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;
|
final resp = await dio.post(
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
'/import/inventory',
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
data: formData,
|
||||||
content: Text('导入完成:$imported 条成功,$skipped 条跳过${errors.isNotEmpty ? ',${errors.length} 条失败' : ''}'),
|
onSendProgress: (sent, total) {
|
||||||
backgroundColor: errors.isEmpty ? Colors.green : AppTheme.accent,
|
if (total <= 0) return;
|
||||||
duration: const Duration(seconds: 4),
|
if (sent >= total && !uploadDone) {
|
||||||
));
|
uploadDone = true;
|
||||||
ref.read(inventoryListProvider.notifier).reload();
|
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) {
|
} on DioException catch (e) {
|
||||||
final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ?? e.message ?? '未知错误';
|
processingTimer?.cancel();
|
||||||
if (!context.mounted) return;
|
final msg = (e.response?.data is Map ? e.response!.data['error'] : null)
|
||||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
?? e.message ?? '未知错误';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
stateNotifier.value = _ImportState(
|
||||||
content: Text('导入失败:$msg'),
|
stage: _ImportStage.error, uploadPercent: 0, errorMsg: msg,
|
||||||
backgroundColor: AppTheme.danger,
|
);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 '../../core/utils/dialog_util.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
@@ -1524,10 +1525,22 @@ class _ImportSlot {
|
|||||||
int total = 0;
|
int total = 0;
|
||||||
int imported = 0;
|
int imported = 0;
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
|
// 进度
|
||||||
|
int uploadPercent = 0;
|
||||||
|
bool isProcessing = false;
|
||||||
|
int processingSeconds = 0;
|
||||||
|
|
||||||
_ImportSlot(this.title, this.endpoint, this.hint);
|
_ImportSlot(this.title, this.endpoint, this.hint);
|
||||||
|
|
||||||
bool get hasResult => success != null;
|
bool get hasResult => success != null;
|
||||||
|
|
||||||
|
void resetProgress() {
|
||||||
|
uploadPercent = 0;
|
||||||
|
isProcessing = false;
|
||||||
|
processingSeconds = 0;
|
||||||
|
success = null;
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BatchImportWidget extends ConsumerStatefulWidget {
|
class _BatchImportWidget extends ConsumerStatefulWidget {
|
||||||
@@ -1657,14 +1670,15 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
for (final s in _slots) {
|
for (final s in _slots) {
|
||||||
if (s.file != null) {
|
if (s.file != null) s.resetProgress();
|
||||||
s.success = null;
|
|
||||||
s.error = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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';
|
dio.options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
|
||||||
for (final slot in _slots) {
|
for (final slot in _slots) {
|
||||||
@@ -1674,29 +1688,56 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
|
if (mounted) setState(() { slot.success = false; slot.error = '无法读取文件'; });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer? processingTimer;
|
||||||
|
bool uploadDone = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'file': MultipartFile.fromBytes(bytes, filename: slot.file!.name),
|
'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>{};
|
final data = (resp.data is Map) ? resp.data as Map<String, dynamic> : <String, dynamic>{};
|
||||||
if (mounted) setState(() {
|
if (mounted) setState(() {
|
||||||
slot.success = true;
|
slot.success = true;
|
||||||
slot.total = (data['total'] ?? data['imported'] ?? 0) as int;
|
slot.total = (data['total'] ?? data['imported'] ?? 0) as int;
|
||||||
slot.imported = (data['imported'] ?? 0) as int;
|
slot.imported = (data['imported'] ?? 0) as int;
|
||||||
slot.skipped = (data['skipped'] ?? 0) as int;
|
slot.skipped = (data['skipped'] ?? 0) as int;
|
||||||
|
slot.isProcessing = false;
|
||||||
});
|
});
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
|
processingTimer?.cancel();
|
||||||
final raw = e.response?.data;
|
final raw = e.response?.data;
|
||||||
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
|
final msg = (raw is Map ? raw['error'] : null) ?? e.message ?? '未知错误';
|
||||||
if (mounted) setState(() {
|
if (mounted) setState(() {
|
||||||
slot.success = false;
|
slot.success = false;
|
||||||
slot.error = msg.toString();
|
slot.error = msg.toString();
|
||||||
|
slot.isProcessing = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
processingTimer?.cancel();
|
||||||
if (mounted) setState(() {
|
if (mounted) setState(() {
|
||||||
slot.success = false;
|
slot.success = false;
|
||||||
slot.error = e.toString();
|
slot.error = e.toString();
|
||||||
|
slot.isProcessing = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2047,10 +2088,42 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (_loading && slot.file != null) {
|
} else if (_loading && slot.file != null) {
|
||||||
statusWidget = const SizedBox(
|
if (slot.isProcessing) {
|
||||||
width: 14, height: 14,
|
statusWidget = Row(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
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(
|
return Padding(
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ server {
|
|||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 文件导入接口(超时更长)
|
||||||
|
location ~ ^/api/v1/import/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
# API 反向代理
|
# API 反向代理
|
||||||
location ~ ^/(api|health|version) {
|
location ~ ^/(api|health|version) {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
|||||||
Reference in New Issue
Block a user