错误处理增强与传输管理改进

December 18, 2025
7 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

一、背景

1.1 原有问题

  1. 错误信息不友好

    • 后端返回的错误直接直接显示(如 “S3 connection failed”)
    • 没有中文本地化
    • 缺少错误分类和具体建议
  2. 传输失败无法重试

    • 上传/下载失败后只能删除重新添加
    • 大文件重传浪费时间和流量
  3. 健康检查信息不直观

    • 调试页面只显示原始 JSON
    • 不太好理解当前状态

二、错误处理增强

2.1 API 错误解析改进

api_client.dart

class ApiClient {
  /// 统一错误处理
  ApiResult<T> _handleError<T>(dynamic error) {
    String message = '未知错误';
    
    if (error is DioException) {
      if (error.response != null) {
        // 尝试解析后端返回的错误信息
        final data = error.response?.data;
        if (data is Map && data.containsKey('error')) {
          message = _localizeError(data['error']);
        } else if (error.response?.statusCode == 401) {
          message = '认证失败,请重新解锁';
        } else if (error.response?.statusCode == 503) {
          message = 'S3 连接失败,请检查配置';
        } else {
          message = '服务器错误 (${error.response?.statusCode})';
        }
      } else if (error.type == DioExceptionType.connectionTimeout) {
        message = '连接超时,请检查网络';
      } else if (error.type == DioExceptionType.receiveTimeout) {
        message = '响应超时,请重试';
      } else if (error.type == DioExceptionType.connectionError) {
        message = '无法连接到服务器';
      } else {
        message = '网络请求失败';
      }
    }
    
    return ApiResult.error(message);
  }
  
  /// 错误本地化
  String _localizeError(String error) {
    // 常见错误映射
    final errorMap = {
      'vault not initialized': '加密库未初始化',
      'vault not unlocked': '加密库未解锁',
      'vault already initialized': '加密库已初始化',
      'incorrect password': '密码错误',
      'file not found': '文件不存在',
      'file already exists': '文件已存在',
      'invalid file name': '文件名无效',
      'system folder cannot be modified': '系统文件夹无法修改',
      'S3 connection failed': 'S3 连接失败',
      'S3_UNAVAILABLE': 'S3 服务不可用',
    };
    
    // 尝试完全匹配
    if (errorMap.containsKey(error)) {
      return errorMap[error]!;
    }
    
    // 尝试部分匹配
    for (final entry in errorMap.entries) {
      if (error.toLowerCase().contains(entry.key.toLowerCase())) {
        return entry.value;
      }
    }
    
    // 返回原始错误(添加前缀)
    return '错误: $error';
  }
}

改进点

  1. 识别 HTTP 状态码并给出对应提示
  2. 识别网络异常类型(超时、连接失败等)
  3. 后端错误信息本地化
  4. 部分匹配机制处理未预见的错误

2.2 AppState 错误传播

class AppState {
  /// 删除文件(增强错误处理)
  Future<void> deleteFile(String id) async {
    final result = await api.deleteFile(id);
    
    if (result.isError) {
      // 错误已经在 ApiClient 中本地化
      showAppToast(context, result.error!);
      return;
    }
    
    // 成功:更新本地状态
    _files.removeWhere((f) => f.id == id);
    await db.deleteFile(id: id, s3ConfigId: _activeS3ConfigId!);
    await refreshFiles();
    
    showAppToast(context, '删除成功');
  }
  
  /// 上传文件(增强错误处理)
  Future<void> uploadFile(String filePath, String remotePath) async {
    try {
      final result = await api.uploadFile(
        filePath: filePath,
        remotePath: remotePath,
      );
      
      if (result.isSuccess) {
        showAppToast(context, '上传成功');
      } else {
        // 具体的错误信息
        showAppToast(context, '上传失败: ${result.error}');
      }
    } catch (e) {
      // 未捕获的异常
      showAppToast(context, '上传异常: $e');
    }
  }
}

三、传输任务重试功能

3.1 重试接口设计

class AppState {
  /// 重试失败的上传任务
  Future<void> retryUpload(String taskId) async {
    // 1. 从数据库加载任务详情
    final task = await db.getTransferById(taskId);
    if (task == null) {
      showAppToast(context, '任务不存在');
      return;
    }
    
    // 2. 检查原始文件是否还存在
    final file = io.File(task.filePath);
    if (!await file.exists()) {
      showAppToast(context, '原始文件不存在,无法重试');
      return;
    }
    
    // 3. 更新任务状态为排队
    await db.updateTransfer(
      id: taskId,
      status: TransferStatus.queued.index,
      error: null,
    );
    
    // 4. 重新加入上传队列
    final job = _UploadJob(
      id: taskId,
      filePath: task.filePath,
      fileName: task.name,
      remotePath: task.remotePath,
    );
    
    _uploadQueue.add(job);
    _uploadJobs[taskId] = job;
    
    // 5. 启动上传处理
    _processUploadQueue();
    
    showAppToast(context, '已重新加入上传队列');
  }
  
  /// 重试失败的下载任务
  Future<void> retryDownload(String taskId) async {
    final task = await db.getTransferById(taskId);
    if (task == null) {
      showAppToast(context, '任务不存在');
      return;
    }
    
    await db.updateTransfer(
      id: taskId,
      status: TransferStatus.queued.index,
      error: null,
    );
    
    final job = _DownloadJob(
      id: taskId,
      file: task.fileMetadata,
      localRelativeDir: task.localDir,
    );
    
    _downloadQueue.add(job);
    _downloadJobs[taskId] = job;
    
    _processDownloadQueue();
    
    showAppToast(context, '已重新加入下载队列');
  }
}

3.2 传输页面重试按钮

// transfers_page.dart
Widget _buildTransferItem(TransferItem item) {
  return ListTile(
    leading: _buildStatusIcon(item.status),
    title: Text(item.name),
    subtitle: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        if (item.size != null)
          Text(_formatSize(item.size!)),
        if (item.status == TransferStatus.running)
          LinearProgressIndicator(value: item.progress),
        if (item.error != null)
          Text(
            item.error!,
            style: TextStyle(color: Colors.red, fontSize: 12),
            maxLines: 2,
            overflow: TextOverflow.ellipsis,
          ),
      ],
    ),
    trailing: _buildActions(item),
  );
}
 
Widget _buildActions(TransferItem item) {
  return Row(
    mainAxisSize: MainAxisSize.min,
    children: [
      // 失败任务显示重试按钮
      if (item.status == TransferStatus.failed)
        IconButton(
          icon: Icon(Icons.refresh, size: 20),
          onPressed: () => _retryTransfer(item),
          tooltip: '重试',
        ),
      
      // 运行中任务显示取消按钮
      if (item.status == TransferStatus.running ||
          item.status == TransferStatus.queued)
        IconButton(
          icon: Icon(Icons.close, size: 20),
          onPressed: () => _cancelTransfer(item),
          tooltip: '取消',
        ),
    ],
  );
}
 
Future<void> _retryTransfer(TransferItem item) async {
  if (item.type == TransferType.upload) {
    await state.retryUpload(item.id);
  } else {
    await state.retryDownload(item.id);
  }
}

四、调试页面健康检查优化

4.1 结构化显示

// debug_page.dart
Future<void> _checkCoreStatus() async {
  final sb = StringBuffer();
  
  // 1. 核心模式
  sb.writeln('━━━ 核心模式 ━━━');
  sb.writeln('模式: ${state.coreMode}');
  sb.writeln('地址: ${state.api.baseUrl}');
  sb.writeln();
  
  // 2. S3 配置
  sb.writeln('━━━ S3 配置 ━━━');
  if (state.hasS3Credentials) {
    sb.writeln('✓ 配置名: ${state.activeS3ConfigName}');
    sb.writeln('✓ 端点: ${state.s3Endpoint}');
    sb.writeln('✓ 桶名: ${state.s3Bucket}');
  } else {
    sb.writeln('✗ 未配置 S3');
  }
  sb.writeln();
  
  // 3. 健康检查
  sb.writeln('━━━ 健康检查 ━━━');
  final healthResult = await state.api.getHealthDetail();
  
  if (healthResult != null) {
    final status = healthResult['status'] ?? 'unknown';
    sb.writeln('核心状态: $status');
    
    if (status == 'ok') {
      sb.writeln('✓ 核心运行正常');
    } else {
      final code = healthResult['code'] ?? '';
      final error = healthResult['error'] ?? '';
      sb.writeln('✗ 错误代码: $code');
      sb.writeln('✗ 错误信息: $error');
      
      // 提供建议
      if (code == 'S3_UNAVAILABLE') {
        sb.writeln();
        sb.writeln('建议:');
        sb.writeln('1. 检查 S3 服务是否运行');
        sb.writeln('2. 检查网络连接');
        sb.writeln('3. 检查 S3 配置是否正确');
      }
    }
  } else {
    sb.writeln('✗ 无法连接到核心');
    sb.writeln();
    sb.writeln('建议:');
    sb.writeln('1. 检查核心是否启动');
    sb.writeln('2. 检查端口是否正确');
    sb.writeln('3. 尝试重启核心');
  }
  sb.writeln();
  
  // 4. Bootstrap 状态
  sb.writeln('━━━ 初始化状态 ━━━');
  final bootstrapResult = await state.api.bootstrap();
  
  if (bootstrapResult.isSuccess) {
    final data = bootstrapResult.data!;
    sb.writeln('状态: ${data.status}');
    
    if (data.status == 'initialized') {
      sb.writeln('✓ 加密库已初始化');
    } else if (data.status == 'empty') {
      sb.writeln('⚠ 桶为空,需要初始化');
    } else if (data.status == 'conflict') {
      sb.writeln('⚠ 桶中有文件但未初始化');
      sb.writeln('  对象数量: ${data.objectCount}');
    }
  } else {
    sb.writeln('✗ 无法获取状态');
  }
  
  // 显示结果
  showDialog(
    context: context,
    builder: (ctx) => AlertDialog(
      title: Text('核心状态'),
      content: SingleChildScrollView(
        child: Text(
          sb.toString(),
          style: TextStyle(fontFamily: 'monospace', fontSize: 12),
        ),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(ctx),
          child: Text('关闭'),
        ),
      ],
    ),
  );
}

改进点

  1. 分区块显示(核心、S3、健康、初始化)
  2. 使用符号标识状态(✓ ✗ ⚠)
  3. 提供问题建议
  4. Monospace 字体,易于阅读

五、离线错误提示优化

5.1 文件页面空态改进

// files_page.dart
Widget _buildEmptyPlaceholder(AppState state) {
  // 区分不同的离线原因
  final s3Failed = state.isOffline && 
      state.healthErrorCode == 'S3_UNAVAILABLE';
  
  if (s3Failed) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.cloud_off, size: 64, color: Colors.grey),
        SizedBox(height: 16),
        Text(
          'S3 连接失败',
          style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
        ),
        SizedBox(height: 8),
        Text(
          '请检查存储服务配置后重试',
          style: TextStyle(color: Colors.grey),
        ),
        SizedBox(height: 24),
        ElevatedButton.icon(
          onPressed: () {
            // 跳转到 S3 配置页面
            Navigator.push(
              context,
              MaterialPageRoute(
                builder: (_) => S3ConfigPage(),
              ),
            );
          },
          icon: Icon(Icons.settings),
          label: Text('检查配置'),
        ),
      ],
    );
  }
  
  if (state.isOffline) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.error_outline, size: 64, color: Colors.grey),
        SizedBox(height: 16),
        Text(
          '内核未启动',
          style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
        ),
        SizedBox(height: 8),
        Text(
          '请启动内核后刷新',
          style: TextStyle(color: Colors.grey),
        ),
        SizedBox(height: 24),
        ElevatedButton.icon(
          onPressed: () => state.refreshHealthStatus(),
          icon: Icon(Icons.refresh),
          label: Text('重试'),
        ),
      ],
    );
  }
  
  // 在线但文件列表为空
  return Center(
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(Icons.folder_open, size: 64, color: Colors.grey),
        SizedBox(height: 16),
        Text('这里空空如也'),
        SizedBox(height: 8),
        Text(
          '点击右下角按钮上传文件',
          style: TextStyle(color: Colors.grey),
        ),
      ],
    ),
  );
}

改进点

  1. 区分 S3 失败和内核未启动
  2. 提供具体的操作建议
  3. 直接跳转到相关设置页面

六、效果对比

6.1 错误提示对比

场景旧版提示新版提示
S3 连接失败S3 connection failedS3 连接失败,请检查配置
密码错误incorrect password密码错误
文件不存在file not found文件不存在
网络超时DioError [ConnectionTimeout]连接超时,请检查网络
系统文件夹system folder cannot be modified系统文件夹无法修改

6.2 传输管理对比

功能旧版新版
失败重试❌ 只能删除重新上传✅ 一键重试
错误信息⚠️ 技术性错误✅ 更顺手的提示
任务状态⚠️ 简单文本✅ 图标 + 进度条 + 错误详情

七、文件变更清单

  • client/lib/core/api/api_client.dart - 增强错误解析和本地化
  • client/lib/core/state/app_state.dart - 添加重试功能
  • client/lib/ui/files_page.dart - 优化错误提示
  • client/lib/ui/transfers_page.dart - 添加重试按钮
  • client/lib/ui/debug_page.dart - 优化健康检查显示

总结:通过错误本地化、传输重试、结构化显示等改进,显著提升了错误处理的使用体验和调试效率。