1141 lines
40 KiB
Dart
1141 lines
40 KiB
Dart
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<SyncSettingsPage> createState() => _SyncSettingsPageState();
|
|
}
|
|
|
|
class _SyncSettingsPageState extends State<SyncSettingsPage> {
|
|
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<SyncProvider>();
|
|
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<SyncProvider>();
|
|
final auth = context.watch<AuthProvider>();
|
|
|
|
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<String>(
|
|
groupValue: _syncMode,
|
|
onChanged: (sync.isActive || sync.isPaused)
|
|
? (_) {}
|
|
: (v) {
|
|
if (v != null) _handleSyncModeChange(v);
|
|
},
|
|
child: Column(
|
|
children: [
|
|
if (Platform.isWindows)
|
|
RadioListTile<String>(
|
|
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<String>(
|
|
title: const Text('全量同步'),
|
|
subtitle: const Text('双向同步所有文件'),
|
|
value: 'full',
|
|
),
|
|
RadioListTile<String>(
|
|
title: const Text('仅上传本地到远程'),
|
|
subtitle: const Text('本地文件同步到远程,不影响远程已有文件'),
|
|
value: 'upload_only',
|
|
),
|
|
RadioListTile<String>(
|
|
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<void> _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<bool?> _showModeConfirmDialog({
|
|
required String title,
|
|
required String description,
|
|
}) {
|
|
return showDialog<bool>(
|
|
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<Widget> 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<void> _pickLocalFolder() async {
|
|
final result = await FilePicker.platform.getDirectoryPath(
|
|
dialogTitle: '选择本地同步目录',
|
|
);
|
|
if (result != null) {
|
|
setState(() => _localRootController.text = result);
|
|
}
|
|
}
|
|
|
|
Future<void> _pickRemoteFolder() async {
|
|
final result = await showDialog<String>(
|
|
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<void> _pickConflictStrategy() async {
|
|
final strategies = [
|
|
('keep_both', '保留两份(重命名本地)'),
|
|
('keep_local', '保留本地版本'),
|
|
('keep_remote', '保留远程版本'),
|
|
('newest_wins', '最新修改优先'),
|
|
('largest_wins', '最大文件优先'),
|
|
('manual', '手动处理'),
|
|
];
|
|
|
|
final result = await showDialog<String>(
|
|
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<void> _pickWcfDeleteMode() async {
|
|
final modes = [
|
|
('wcf_delete_local_only', '仅删除本地(可重新水合)'),
|
|
('wcf_delete_sync_remote', '同步删除远程'),
|
|
];
|
|
|
|
final result = await showDialog<String>(
|
|
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<void> _pickConcurrency() async {
|
|
final values = [1, 2, 3, 5, 8];
|
|
final result = await showDialog<int>(
|
|
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<void> _pickMaxWorkers() async {
|
|
final controller = TextEditingController(text: _maxWorkers.toString());
|
|
final result = await showDialog<int>(
|
|
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<void> _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<int>(
|
|
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<SyncProvider>();
|
|
|
|
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<void> _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<void> _stopSync(SyncProvider sync) async {
|
|
final confirmed = await showDialog<bool>(
|
|
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<void> _resetSync(SyncProvider sync) async {
|
|
final confirmed = await showDialog<bool>(
|
|
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<String> _getSyncLogPath() async {
|
|
final appDir = await getApplicationSupportDirectory();
|
|
return '${appDir.path}${Platform.pathSeparator}sync_core${Platform.pathSeparator}logs${Platform.pathSeparator}sync_log.txt';
|
|
}
|
|
|
|
Future<void> _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<void> _pickLogLevel() async {
|
|
final levels = [
|
|
('error', 'Error — 仅错误'),
|
|
('warn', 'Warn — 错误 + 警告'),
|
|
('info', 'Info — 常规信息'),
|
|
('debug', 'Debug — 调试信息'),
|
|
('trace', 'Trace — 全量追踪'),
|
|
];
|
|
|
|
final result = await showDialog<String>(
|
|
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<SyncProvider>();
|
|
await sync.setLogLevel(result);
|
|
|
|
if (mounted) {
|
|
ToastHelper.success('日志级别已切换为 ${_logLevelLabel(result)}');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _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<void> _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<void> _previewSyncLog() async {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => const SyncLogViewerPage(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _clearSyncLog() async {
|
|
final confirmed = await showDialog<bool>(
|
|
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('同步日志已清空');
|
|
}
|
|
}
|
|
}
|