import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:open_file/open_file.dart'; import '../../../core/constants/sync_defaults.dart'; import '../../../core/utils/app_logger.dart'; import '../../../data/models/sync_config_model.dart'; import '../../providers/auth_provider.dart'; import '../../providers/sync_provider.dart'; import '../../widgets/desktop_constrained.dart'; import '../../widgets/folder_picker.dart'; import '../../widgets/toast_helper.dart'; import 'sync_log_viewer_page.dart'; /// 同步设置页面 class SyncSettingsPage extends StatefulWidget { const SyncSettingsPage({super.key}); @override State createState() => _SyncSettingsPageState(); } class _SyncSettingsPageState extends State { late TextEditingController _localRootController; String _remoteRoot = SyncDefaults.defaultRemoteRoot; String _syncMode = 'full'; String _conflictStrategy = 'keep_both'; String _wcfDeleteMode = 'wcf_delete_local_only'; int _maxConcurrent = 3; int _bandwidthLimitKbps = 0; int _maxWorkers = SyncDefaults.defaultMaxWorkers; String _logLevel = 'info'; String _syncLogFilePath = ''; int? _syncLogFileSize; @override void initState() { super.initState(); _localRootController = TextEditingController( text: SyncDefaults.defaultLocalRoot(), ); AppLogger.i('默认同步目录: ${_localRootController.text}'); WidgetsBinding.instance.addPostFrameCallback((_) { final sync = context.read(); final config = sync.persistedConfig; if (config != null) { setState(() { _localRootController.text = config.localRoot; _remoteRoot = config.remoteRoot; _syncMode = config.syncMode; _conflictStrategy = config.conflictStrategy; _wcfDeleteMode = config.wcfDeleteMode; _maxConcurrent = config.maxConcurrentTransfers; _bandwidthLimitKbps = config.bandwidthLimitKbps; _maxWorkers = config.maxWorkers; _logLevel = config.logLevel; }); } _loadSyncLogInfo(); }); } @override void dispose() { _localRootController.dispose(); super.dispose(); } bool get _isDesktop => Platform.isWindows || Platform.isLinux; @override Widget build(BuildContext context) { final sync = context.watch(); final auth = context.watch(); return Scaffold( appBar: AppBar(title: const Text('文件同步')), body: DesktopConstrained( child: ListView( children: [ _buildSyncStatus(sync), if (_isDesktop) ...[ _buildSection( title: '同步目录', children: [ ListTile( leading: const Icon(Icons.folder_outlined), title: const Text('本地同步目录'), subtitle: Text(_localRootController.text), trailing: const Icon(Icons.chevron_right), onTap: () => _pickLocalFolder(), ), ListTile( leading: const Icon(Icons.cloud_outlined), title: const Text('远程同步目录'), subtitle: Text(_remoteRoot), trailing: const Icon(Icons.chevron_right), onTap: () => _pickRemoteFolder(), ), ], ), _buildSection( title: '同步模式', children: [ if (sync.isActive || sync.isPaused) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( '同步运行中,无法切换模式。请先停止同步再修改。', style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12), ), ), RadioGroup( groupValue: _syncMode, onChanged: (sync.isActive || sync.isPaused) ? (_) {} : (v) { if (v != null) _handleSyncModeChange(v); }, child: Column( children: [ if (Platform.isWindows) RadioListTile( title: Row( mainAxisSize: MainAxisSize.min, children: [ const Text('镜像同步'), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text( '推荐', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.primary, ), ), ), ], ), subtitle: const Text('本地仅保留占位符,打开文件时自动下载'), value: 'mirror_wcf', ), RadioListTile( title: const Text('全量同步'), subtitle: const Text('双向同步所有文件'), value: 'full', ), RadioListTile( title: const Text('仅上传本地到远程'), subtitle: const Text('本地文件同步到远程,不影响远程已有文件'), value: 'upload_only', ), RadioListTile( title: const Text('仅下载远程到本地'), subtitle: const Text('远程文件同步到本地,本地修改不影响远程'), value: 'download_only', ), ], ), ), ], ), if (_syncMode == 'full') _buildSection( title: '冲突处理', children: [ ListTile( leading: const Icon(Icons.merge_outlined), title: const Text('冲突解决策略'), subtitle: (sync.isActive || sync.isPaused) ? const Text('同步运行中,无法修改') : Text(_conflictStrategyLabel(_conflictStrategy)), trailing: const Icon(Icons.chevron_right), onTap: (sync.isActive || sync.isPaused) ? null : () => _pickConflictStrategy(), ), ], ), if (_syncMode == 'mirror_wcf') _buildSection( title: '镜像删除模式', children: [ ListTile( leading: const Icon(Icons.delete_outline), title: const Text('本地删除行为'), subtitle: (sync.isActive || sync.isPaused) ? const Text('同步运行中,无法修改') : Text(_wcfDeleteModeLabel(_wcfDeleteMode)), trailing: const Icon(Icons.chevron_right), onTap: (sync.isActive || sync.isPaused) ? null : () => _pickWcfDeleteMode(), ), ], ), ], if (Platform.isAndroid) _buildSection( title: '相册同步', children: [ SwitchListTile( title: const Text('自动备份相册'), subtitle: const Text('将手机照片自动备份到云端'), value: _syncMode == 'album', onChanged: (v) { setState(() => _syncMode = v ? 'album' : 'full'); _pushConfig(); }, ), ], ), _buildSection( title: '性能', children: [ if (_isDesktop) ListTile( leading: const Icon(Icons.work_outline), title: const Text('最大并发任务数'), subtitle: Text(_maxWorkersLabel), trailing: const Icon(Icons.chevron_right), onTap: () => _pickMaxWorkers(), ), ListTile( leading: const Icon(Icons.sync_outlined), title: const Text('最大并发传输数'), subtitle: Text('$_maxConcurrent'), trailing: const Icon(Icons.chevron_right), onTap: () => _pickConcurrency(), ), ListTile( leading: const Icon(Icons.speed_outlined), title: const Text('带宽限制'), subtitle: Text( _bandwidthLimitKbps > 0 ? '${(_bandwidthLimitKbps / 1024).toStringAsFixed(1)} MB/s' : '不限制', ), trailing: const Icon(Icons.chevron_right), onTap: () => _pickBandwidthLimit(), ), ], ), _buildSection( title: '同步控制', children: [ if (!sync.isActive && !sync.isPaused) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: () => _startSync(auth, sync), icon: const Icon(Icons.play_arrow), label: const Text('开始同步'), ), ), ), if (sync.isActive || sync.isPaused) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ if (sync.isActive) Expanded( child: OutlinedButton.icon( onPressed: () => sync.pause(), icon: const Icon(Icons.pause), label: const Text('暂停'), ), ), if (sync.isPaused) Expanded( child: FilledButton.icon( onPressed: () => sync.resume(), icon: const Icon(Icons.play_arrow), label: const Text('恢复'), ), ), const SizedBox(width: 12), Expanded( child: OutlinedButton.icon( onPressed: () => _stopSync(sync), icon: const Icon(Icons.stop), label: const Text('停止'), style: OutlinedButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.error, ), ), ), ], ), ), if (sync.isActive) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () => sync.forceSync(), icon: const Icon(Icons.refresh), label: const Text('强制重新同步'), ), ), ), if (sync.engineInitialized) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: () => _resetSync(sync), icon: const Icon(Icons.delete_forever), label: const Text('重置同步'), style: OutlinedButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.error, ), ), ), ), ], ), _buildSection( title: '同步日志', children: [ ListTile( leading: const Icon(Icons.tune), title: const Text('日志级别'), subtitle: Text(_logLevelLabel(_logLevel)), trailing: const Icon(Icons.chevron_right), onTap: () => _pickLogLevel(), ), ListTile( title: const Text('日志文件路径'), subtitle: Text( _syncLogFilePath.isNotEmpty ? _syncLogFilePath : '未初始化', style: const TextStyle(fontSize: 11), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), ListTile( title: const Text('日志文件大小'), subtitle: Text(_formatBytes(_syncLogFileSize)), ), if (!Platform.isAndroid) ListTile( leading: const Icon(Icons.folder_open), title: const Text('打开日志目录'), trailing: const Icon(Icons.chevron_right), onTap: _openSyncLogFolder, ), ListTile( leading: const Icon(Icons.file_download_outlined), title: const Text('导出日志'), subtitle: const Text('导出到 Download 目录'), trailing: const Icon(Icons.chevron_right), onTap: _exportSyncLog, ), ListTile( leading: const Icon(Icons.visibility_outlined), title: const Text('预览日志'), trailing: const Icon(Icons.chevron_right), onTap: _previewSyncLog, ), ListTile( leading: const Icon(Icons.delete_outline, color: Colors.red), title: const Text('清空日志'), trailing: const Icon(Icons.chevron_right), onTap: _clearSyncLog, ), ], ), const SizedBox(height: 32), ], ), ), ); } String get _maxWorkersLabel { if (_maxWorkers == 0) return '自动 (CPU核心数)'; return '$_maxWorkers'; } Future _handleSyncModeChange(String newMode) async { if (newMode == 'full') { final confirmed = await _showModeConfirmDialog( title: '全量同步', description: '此模式下:\n\n' '• 本地和远程双向同步所有文件\n' '• 本地新增、修改的文件将上传到远程\n' '• 远程新增、修改的文件将下载到本地\n' '• 本地删除会同步删除远程副本,反之亦然\n' '• 本地重命名、移动会在远程同步操作,反之亦然\n' '• 冲突时根据所选策略处理\n\n' '适用于需要在本地和远程之间保持完全一致的场景。', ); if (confirmed != true) return; } else if (newMode == 'upload_only') { final confirmed = await _showModeConfirmDialog( title: '仅上传本地到远程', description: '此模式下:\n\n' '• 本地新增、修改的文件将上传到远程\n' '• 本地重命名、移动的文件会在远程同步操作\n' '• 本地删除的文件不会删除远程副本\n' '• 不会下载远程新增或修改的文件\n' '• 不监听远程事件(不消耗 SSE 连接)\n\n' '适用于只需要备份本地文件到云端的场景。', ); if (confirmed != true) return; } else if (newMode == 'download_only') { final confirmed = await _showModeConfirmDialog( title: '仅下载远程到本地', description: '此模式下:\n\n' '• 远程新增、修改的文件将下载到本地\n' '• 远程删除的文件将同步删除本地副本\n' '• 远程重命名、移动会在本地同步\n' '• 本地的所有增删改操作不会影响远程\n' '• 本地删除的文件如果远程仍存在会重新下载\n' '• 不监听本地文件变化\n\n' '适用于只需要在本地保留远程副本的场景。', ); if (confirmed != true) return; } else if (newMode == 'mirror_wcf') { final confirmed = await _showModeConfirmDialog( title: '镜像同步', description: '此模式下(仅 Windows):\n\n' '• 远程文件以占位符形式出现在本地\n' '• 占位符不占用磁盘空间,但在资源管理器中可见\n' '• 打开文件时自动从云端下载(水合)\n' '• 本地修改会同步回远程\n' '• 默认仅删除本地,保留远程副本以便重新水合\n' '• 可手动将文件恢复为占位符(脱水)以释放空间\n\n' '适用于远程文件较多但本地磁盘空间有限的场景。', ); if (confirmed != true) return; } setState(() => _syncMode = newMode); } Future _showModeConfirmDialog({ required String title, required String description, }) { return showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(title), content: Text(description), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('确认切换'), ), ], ), ); } Widget _buildSyncStatus(SyncProvider sync) { return _buildSection( title: '同步状态', children: [ ListTile( leading: Icon( _stateIcon(sync), color: _stateColor(sync), ), title: Text(_stateLabel(sync)), subtitle: sync.hasError && sync.errorMessage != null ? Text( sync.errorMessage!, style: TextStyle(color: Theme.of(context).colorScheme.error), maxLines: 2, overflow: TextOverflow.ellipsis, ) : null, ), if (sync.isActive || sync.isPaused) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: LinearProgressIndicator( value: sync.totalFiles > 0 ? sync.progress : null, ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( sync.totalFiles > 0 ? '${sync.syncedFiles} / ${sync.totalFiles} 文件' : sync.state == SyncState.continuous ? '持续同步中' : '正在同步...', style: Theme.of(context).textTheme.bodySmall, ), ), if (sync.currentFile != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( sync.currentFile!, style: Theme.of(context).textTheme.bodySmall, overflow: TextOverflow.ellipsis, maxLines: 1, ), ), ], if (sync.lastSummary != null) ...[ const Divider(indent: 16, endIndent: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Wrap( spacing: 16, children: [ _summaryChip('上传', sync.lastSummary!.uploaded), _summaryChip('下载', sync.lastSummary!.downloaded), _summaryChip('冲突', sync.lastSummary!.conflicts), _summaryChip('失败', sync.lastSummary!.failed), _summaryChip('跳过', sync.lastSummary!.skipped), _summaryChip('删本地', sync.lastSummary!.deletedLocal), _summaryChip('删远程', sync.lastSummary!.deletedRemote), ], ), ), ], ], ); } Widget _summaryChip(String label, int value) { return Chip( label: Text('$label: $value'), labelStyle: Theme.of(context).textTheme.bodySmall, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, ); } IconData _stateIcon(SyncProvider sync) { if (sync.isActive) return Icons.sync; if (sync.isPaused) return Icons.pause_circle_outline; if (sync.hasError) return Icons.error_outline; if (sync.state == SyncState.stopped) return Icons.stop_circle_outlined; return Icons.cloud_off; } Color _stateColor(SyncProvider sync) { if (sync.isActive) return Theme.of(context).colorScheme.primary; if (sync.isPaused) return Colors.orange; if (sync.hasError) return Theme.of(context).colorScheme.error; return Theme.of(context).disabledColor; } String _stateLabel(SyncProvider sync) { return switch (sync.state) { SyncState.idle => '未启动', SyncState.initializing => '正在初始化...', SyncState.initialSync => '初始同步中', SyncState.continuous => '持续同步中', SyncState.paused => '已暂停', SyncState.error => '同步错误', SyncState.stopped => '已停止', }; } String _conflictStrategyLabel(String strategy) { return switch (strategy) { 'keep_local' => '保留本地版本', 'keep_remote' => '保留远程版本', 'keep_both' => '保留两份(重命名本地)', 'newest_wins' => '最新修改优先', 'largest_wins' => '最大文件优先', 'manual' => '手动处理', _ => strategy, }; } String _wcfDeleteModeLabel(String mode) { return switch (mode) { 'wcf_delete_local_only' => '仅删除本地(可重新水合)', 'wcf_delete_sync_remote' => '同步删除远程', _ => mode, }; } Widget _buildSection({ required String title, required List children, }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( title, style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, ), ), ), Card( margin: const EdgeInsets.symmetric(horizontal: 16), child: Column(children: children), ), ], ), ); } Future _pickLocalFolder() async { final result = await FilePicker.platform.getDirectoryPath( dialogTitle: '选择本地同步目录', ); if (result != null) { setState(() => _localRootController.text = result); } } Future _pickRemoteFolder() async { final result = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('选择远程同步目录'), content: SizedBox( width: 400, height: 500, child: FolderPicker( currentPath: _remoteRoot, onFolderSelected: (path) => Navigator.pop(ctx, path), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('取消'), ), ], ), ); if (result != null && result.isNotEmpty) { setState(() { _remoteRoot = result == '/' ? SyncDefaults.defaultRemoteRoot : '${SyncDefaults.defaultRemoteRoot}${result.startsWith('/') ? result : '/$result'}'; }); } } Future _pickConflictStrategy() async { final strategies = [ ('keep_both', '保留两份(重命名本地)'), ('keep_local', '保留本地版本'), ('keep_remote', '保留远程版本'), ('newest_wins', '最新修改优先'), ('largest_wins', '最大文件优先'), ('manual', '手动处理'), ]; final result = await showDialog( context: context, builder: (ctx) => SimpleDialog( title: const Text('冲突解决策略'), children: strategies .map( (e) => SimpleDialogOption( onPressed: () => Navigator.pop(ctx, e.$1), child: Row( children: [ Icon( e.$1 == _conflictStrategy ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 20, ), const SizedBox(width: 8), Text(e.$2), ], ), ), ) .toList(), ), ); if (result != null) { setState(() => _conflictStrategy = result); _pushConfig(); } } Future _pickWcfDeleteMode() async { final modes = [ ('wcf_delete_local_only', '仅删除本地(可重新水合)'), ('wcf_delete_sync_remote', '同步删除远程'), ]; final result = await showDialog( context: context, builder: (ctx) => SimpleDialog( title: const Text('镜像删除模式'), children: modes .map( (e) => SimpleDialogOption( onPressed: () => Navigator.pop(ctx, e.$1), child: Row( children: [ Icon( e.$1 == _wcfDeleteMode ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 20, ), const SizedBox(width: 8), Text(e.$2), ], ), ), ) .toList(), ), ); if (result != null) { setState(() => _wcfDeleteMode = result); _pushConfig(); } } Future _pickConcurrency() async { final values = [1, 2, 3, 5, 8]; final result = await showDialog( context: context, builder: (ctx) => SimpleDialog( title: const Text('最大并发传输数'), children: values .map( (v) => SimpleDialogOption( onPressed: () => Navigator.pop(ctx, v), child: Row( children: [ Icon( v == _maxConcurrent ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 20, ), const SizedBox(width: 8), Text('$v'), ], ), ), ) .toList(), ), ); if (result != null) { setState(() => _maxConcurrent = result); _pushConfig(); } } Future _pickMaxWorkers() async { final controller = TextEditingController(text: _maxWorkers.toString()); final result = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('最大并发任务数'), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: controller, keyboardType: TextInputType.number, decoration: const InputDecoration( hintText: '0 = 自动 (CPU核心数)', border: OutlineInputBorder(), ), ), const SizedBox(height: 8), Text( '0 表示自动等于 CPU 核心数\n最大不超过 CPU 核心数的 2 倍,超出无效', style: Theme.of(ctx).textTheme.bodySmall?.copyWith( color: Theme.of(ctx).hintColor, ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('取消'), ), FilledButton( onPressed: () { final val = int.tryParse(controller.text) ?? 0; Navigator.pop(ctx, val.clamp(0, 999)); }, child: const Text('确定'), ), ], ), ); if (result != null) { setState(() => _maxWorkers = result); _pushConfig(); } } Future _pickBandwidthLimit() async { final options = [ (0, '不限制'), (1024, '1 MB/s'), (2048, '2 MB/s'), (5120, '5 MB/s'), (10240, '10 MB/s'), ]; final result = await showDialog( context: context, builder: (ctx) => SimpleDialog( title: const Text('带宽限制'), children: options .map( (e) => SimpleDialogOption( onPressed: () => Navigator.pop(ctx, e.$1), child: Row( children: [ Icon( e.$1 == _bandwidthLimitKbps ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 20, ), const SizedBox(width: 8), Text(e.$2), ], ), ), ) .toList(), ), ); if (result != null) { setState(() => _bandwidthLimitKbps = result); _pushConfig(); } } void _pushConfig() { final sync = context.read(); final config = sync.persistedConfig; if (config == null) return; final updated = config.copyWith( syncMode: _syncMode, conflictStrategy: _conflictStrategy, wcfDeleteMode: _wcfDeleteMode, maxConcurrentTransfers: _maxConcurrent, bandwidthLimitKbps: _bandwidthLimitKbps, maxWorkers: _maxWorkers, logLevel: _logLevel, ); sync.updateConfig(updated); } Future _startSync(AuthProvider auth, SyncProvider sync) async { final server = auth.currentServer; final token = auth.token; if (server == null || token == null) { ToastHelper.failure('请先登录'); return; } final appSupportDir = await getApplicationSupportDirectory(); final config = SyncConfigModel( baseUrl: server.baseUrl, accessToken: token.accessToken, refreshToken: token.refreshToken, localRoot: _localRootController.text, remoteRoot: _remoteRoot, syncMode: _syncMode, conflictStrategy: _conflictStrategy, wcfDeleteMode: _wcfDeleteMode, maxConcurrentTransfers: _maxConcurrent, bandwidthLimitKbps: _bandwidthLimitKbps, maxWorkers: _maxWorkers, dataDir: appSupportDir.path, clientId: '', logLevel: _logLevel, ); await sync.startSync(config); } Future _stopSync(SyncProvider sync) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('停止同步'), content: const Text('确定要停止文件同步吗?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, ), child: const Text('停止'), ), ], ), ); if (confirmed == true) { await sync.stop(); } } Future _resetSync(SyncProvider sync) async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('重置同步'), content: const Text( '此操作将:\n\n' '• 停止当前同步任务\n' '• 清空同步数据库(任务记录、文件映射)\n' '• 删除本地同步目录中的所有文件(不影响远程)\n\n' '重置后需重新点击"开始同步"。此操作不可恢复。', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, ), child: const Text('重置'), ), ], ), ); if (confirmed == true) { await sync.resetSync(); if (mounted) ToastHelper.success('同步已重置'); } } // ===== 同步日志 ===== Future _getSyncLogPath() async { final appDir = await getApplicationSupportDirectory(); return '${appDir.path}${Platform.pathSeparator}sync_core${Platform.pathSeparator}logs${Platform.pathSeparator}sync_log.txt'; } Future _loadSyncLogInfo() async { final path = await _getSyncLogPath(); final file = File(path); int? size; if (await file.exists()) { size = await file.length(); } if (mounted) { setState(() { _syncLogFilePath = path; _syncLogFileSize = size; }); } } String _formatBytes(int? bytes) { if (bytes == null) return '未知'; if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } String _logLevelLabel(String level) { return switch (level) { 'error' => 'Error — 仅错误', 'warn' => 'Warn — 错误 + 警告', 'info' => 'Info — 常规信息', 'debug' => 'Debug — 调试信息', 'trace' => 'Trace — 全量追踪', _ => level, }; } Future _pickLogLevel() async { final levels = [ ('error', 'Error — 仅错误'), ('warn', 'Warn — 错误 + 警告'), ('info', 'Info — 常规信息'), ('debug', 'Debug — 调试信息'), ('trace', 'Trace — 全量追踪'), ]; final result = await showDialog( context: context, builder: (ctx) => SimpleDialog( title: const Text('日志级别'), children: levels .map( (e) => SimpleDialogOption( onPressed: () => Navigator.pop(ctx, e.$1), child: Row( children: [ Icon( e.$1 == _logLevel ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 20, ), const SizedBox(width: 8), Text(e.$2), ], ), ), ) .toList(), ), ); if (result != null && result != _logLevel) { setState(() => _logLevel = result); if (!mounted) return; // 热修改:立即通知 Rust 引擎 final sync = context.read(); await sync.setLogLevel(result); if (mounted) { ToastHelper.success('日志级别已切换为 ${_logLevelLabel(result)}'); } } } Future _openSyncLogFolder() async { try { if (_syncLogFilePath.isEmpty) { ToastHelper.error('日志文件路径未获取'); return; } final dir = File(_syncLogFilePath).parent.path; final result = await OpenFile.open(dir); if (result.type != ResultType.done) { if (mounted) ToastHelper.error('无法打开目录:${result.message}'); } } catch (e) { if (mounted) ToastHelper.error('打开目录失败:$e'); } } Future _exportSyncLog() async { try { final srcPath = await _getSyncLogPath(); final srcFile = File(srcPath); if (!await srcFile.exists()) { if (mounted) ToastHelper.error('日志文件不存在'); return; } final downloadDir = await getDownloadsDirectory(); if (downloadDir == null) { if (mounted) ToastHelper.error('无法获取下载目录'); return; } final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').substring(0, 19); final destPath = '${downloadDir.path}${Platform.pathSeparator}sync_core_log_$timestamp.txt'; await srcFile.copy(destPath); if (mounted) ToastHelper.success('日志已导出到:$destPath'); } catch (e) { if (mounted) ToastHelper.error('导出日志失败:$e'); } } Future _previewSyncLog() async { Navigator.of(context).push( MaterialPageRoute( builder: (_) => const SyncLogViewerPage(), ), ); } Future _clearSyncLog() async { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('清空同步日志'), content: const Text('确定要清空同步引擎日志文件内容吗?此操作不可恢复。'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error), child: const Text('清空'), ), ], ), ); if (confirmed == true) { final path = await _getSyncLogPath(); final file = File(path); if (await file.exists()) { await file.writeAsString(''); } await _loadSyncLogInfo(); if (mounted) ToastHelper.success('同步日志已清空'); } } }