主要合入内容:

- 文件管理器分页加载、排序偏好、桌面表头排序、移动端排序菜单、Ctrl/Cmd+F搜索、Esc 关闭搜索、鼠标右滑返回上级。
- 移动端同步状态页、同步统计卡片、同步任务/累计统计事件、同步页布局更新。
- Android 相册同步默认路径改为 DCIM/Camera,下载路径改用 external_path。
- Rust sync-core 更新:Android 日志依赖、AlbumUpload/AlbumDownload、MirrorWcf 统计修复、Linux FUSE镜像挂载与读写同步支持。

- 依赖更新:external_path、fl_chart、pdfrx 升级,并修了 pdfrx 新版本的deprecated API。
- 新增文件包括:sort_options.dart、sync_page_android.dart、sync_stats_card.dart、platform/fuse.rs、sync_engine/fuse.rs。
This commit is contained in:
2026-06-04 07:11:43 +08:00
parent 5ee6ba1e28
commit b02daf1448
56 changed files with 7589 additions and 1200 deletions
@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:external_path/external_path.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:open_file/open_file.dart';
@@ -10,8 +11,11 @@ import '../../../core/utils/app_logger.dart';
import '../../../data/models/sync_config_model.dart';
import '../../providers/auth_provider.dart';
import '../../providers/sync_provider.dart';
import '../../../services/sync_service.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../widgets/desktop_constrained.dart';
import '../../widgets/folder_picker.dart';
import '../../widgets/sync_stats_card.dart';
import '../../widgets/toast_helper.dart';
import 'sync_log_viewer_page.dart';
@@ -42,6 +46,13 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
_localRootController = TextEditingController(
text: SyncDefaults.defaultLocalRoot(),
);
if (Platform.isAndroid) {
_syncMode = SyncDefaults.defaultAndroidSyncMode;
_remoteRoot = SyncDefaults.defaultAndroidRemoteRoot;
SyncDefaults.getDefaultAndroidLocalRoot().then((path) {
if (mounted) setState(() => _localRootController.text = path);
});
}
AppLogger.i('默认同步目录: ${_localRootController.text}');
@@ -60,6 +71,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
_maxWorkers = config.maxWorkers;
_logLevel = config.logLevel;
});
_applyAlbumPaths();
}
_loadSyncLogInfo();
});
@@ -109,10 +121,16 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
children: [
if (sync.isActive || sync.isPaused)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'同步运行中,无法切换模式。请先停止同步再修改。',
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
RadioGroup<String>(
@@ -124,7 +142,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
},
child: Column(
children: [
if (Platform.isWindows)
if (Platform.isWindows || Platform.isLinux)
RadioListTile<String>(
title: Row(
mainAxisSize: MainAxisSize.min,
@@ -132,9 +150,13 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
const Text('镜像同步'),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
color: Theme.of(context).colorScheme.primary
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
@@ -142,7 +164,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
color: Theme.of(
context,
).colorScheme.primary,
),
),
),
@@ -182,7 +206,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
? const Text('同步运行中,无法修改')
: Text(_conflictStrategyLabel(_conflictStrategy)),
trailing: const Icon(Icons.chevron_right),
onTap: (sync.isActive || sync.isPaused) ? null : () => _pickConflictStrategy(),
onTap: (sync.isActive || sync.isPaused)
? null
: () => _pickConflictStrategy(),
),
],
),
@@ -197,7 +223,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
? const Text('同步运行中,无法修改')
: Text(_wcfDeleteModeLabel(_wcfDeleteMode)),
trailing: const Icon(Icons.chevron_right),
onTap: (sync.isActive || sync.isPaused) ? null : () => _pickWcfDeleteMode(),
onTap: (sync.isActive || sync.isPaused)
? null
: () => _pickWcfDeleteMode(),
),
],
),
@@ -206,14 +234,41 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
_buildSection(
title: '相册同步',
children: [
SwitchListTile(
title: const Text('自动备份相册'),
subtitle: const Text('将手机照片自动备份到云端'),
value: _syncMode == 'album',
onChanged: (v) {
setState(() => _syncMode = v ? 'album' : 'full');
_pushConfig();
},
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) _handleAlbumModeChange(v);
},
child: Column(
children: [
RadioListTile<String>(
title: const Text('仅上传'),
subtitle: const Text('备份手机照片到云端'),
value: 'album_upload',
),
RadioListTile<String>(
title: const Text('仅下载'),
subtitle: const Text('从云端下载照片到手机'),
value: 'album_download',
),
],
),
),
],
),
@@ -253,7 +308,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
children: [
if (!sync.isActive && !sync.isPaused)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
@@ -265,7 +323,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
),
if (sync.isActive || sync.isPaused)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
if (sync.isActive)
@@ -291,7 +352,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
icon: const Icon(Icons.stop),
label: const Text('停止'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(
context,
).colorScheme.error,
),
),
),
@@ -300,7 +363,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
),
if (sync.isActive)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
@@ -312,7 +378,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
),
if (sync.engineInitialized)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
@@ -394,7 +463,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
if (newMode == 'full') {
final confirmed = await _showModeConfirmDialog(
title: '全量同步',
description: '此模式下:\n\n'
description:
'此模式下:\n\n'
'• 本地和远程双向同步所有文件\n'
'• 本地新增、修改的文件将上传到远程\n'
'• 远程新增、修改的文件将下载到本地\n'
@@ -407,7 +477,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
} else if (newMode == 'upload_only') {
final confirmed = await _showModeConfirmDialog(
title: '仅上传本地到远程',
description: '此模式下:\n\n'
description:
'此模式下:\n\n'
'• 本地新增、修改的文件将上传到远程\n'
'• 本地重命名、移动的文件会在远程同步操作\n'
'• 本地删除的文件不会删除远程副本\n'
@@ -419,7 +490,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
} else if (newMode == 'download_only') {
final confirmed = await _showModeConfirmDialog(
title: '仅下载远程到本地',
description: '此模式下:\n\n'
description:
'此模式下:\n\n'
'• 远程新增、修改的文件将下载到本地\n'
'• 远程删除的文件将同步删除本地副本\n'
'• 远程重命名、移动会在本地同步\n'
@@ -432,7 +504,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
} else if (newMode == 'mirror_wcf') {
final confirmed = await _showModeConfirmDialog(
title: '镜像同步',
description: '此模式下(仅 Windows):\n\n'
description:
'此模式下(仅 Windows | Linux):\n\n'
'• 远程文件以占位符形式出现在本地\n'
'• 占位符不占用磁盘空间,但在资源管理器中可见\n'
'• 打开文件时自动从云端下载(水合)\n'
@@ -447,6 +520,27 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
setState(() => _syncMode = newMode);
}
void _handleAlbumModeChange(String mode) {
setState(() {
_syncMode = mode;
_remoteRoot = SyncDefaults.defaultAndroidRemoteRoot;
});
SyncDefaults.getDefaultAndroidLocalRoot().then((path) {
if (mounted) setState(() => _localRootController.text = path);
});
_pushConfig();
}
/// 当前模式为相册模式时,自动设置写死路径
Future<void> _applyAlbumPaths() async {
if (!Platform.isAndroid) return;
if (_syncMode == 'album_upload' || _syncMode == 'album_download') {
final path = await SyncDefaults.getDefaultAndroidLocalRoot();
_localRootController.text = path;
_remoteRoot = SyncDefaults.defaultAndroidRemoteRoot;
}
}
Future<bool?> _showModeConfirmDialog({
required String title,
required String description,
@@ -475,10 +569,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
title: '同步状态',
children: [
ListTile(
leading: Icon(
_stateIcon(sync),
color: _stateColor(sync),
),
leading: Icon(_stateIcon(sync), color: _stateColor(sync)),
title: Text(_stateLabel(sync)),
subtitle: sync.hasError && sync.errorMessage != null
? Text(
@@ -502,8 +593,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
sync.totalFiles > 0
? '${sync.syncedFiles} / ${sync.totalFiles} 文件'
: sync.state == SyncState.continuous
? '持续同步中'
: '正在同步...',
? '持续同步中'
: '正在同步...',
style: Theme.of(context).textTheme.bodySmall,
),
),
@@ -518,21 +609,21 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
),
),
],
if (sync.lastSummary != null) ...[
// 实时累积统计卡片
if (sync.engineInitialized) ...[
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),
],
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: SyncStatsCard(
uploaded: sync.cumUploaded,
downloaded: sync.cumDownloaded,
renamed: sync.cumRenamed,
moved: sync.cumMoved,
conflicts: sync.cumConflicts,
failed: sync.cumFailed,
deletedLocal: sync.cumDeletedLocal,
deletedRemote: sync.cumDeletedRemote,
skipped: sync.cumSkipped,
),
),
],
@@ -540,15 +631,6 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
);
}
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;
@@ -797,9 +879,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
const SizedBox(height: 8),
Text(
'0 表示自动等于 CPU 核心数\n最大不超过 CPU 核心数的 2 倍,超出无效',
style: Theme.of(ctx).textTheme.bodySmall?.copyWith(
color: Theme.of(ctx).hintColor,
),
style: Theme.of(
ctx,
).textTheme.bodySmall?.copyWith(color: Theme.of(ctx).hintColor),
),
],
),
@@ -871,6 +953,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
if (config == null) return;
final updated = config.copyWith(
localRoot: _localRootController.text,
remoteRoot: _remoteRoot,
syncMode: _syncMode,
conflictStrategy: _conflictStrategy,
wcfDeleteMode: _wcfDeleteMode,
@@ -890,6 +974,67 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
return;
}
// Android 相册模式:先申请对应权限
if (Platform.isAndroid && _syncMode == 'album_upload') {
final statuses = await [Permission.photos, Permission.videos].request();
if (!statuses[Permission.photos]!.isGranted ||
!statuses[Permission.videos]!.isGranted) {
if (mounted) {
ToastHelper.failure('需要相册和视频权限才能同步');
final shouldOpen = 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),
child: const Text('去设置'),
),
],
),
);
if (shouldOpen == true) {
await openAppSettings();
}
}
return;
}
} else if (Platform.isAndroid && _syncMode == 'album_download') {
// 仅下载模式需要写入 Camera 目录,必须拥有所有文件管理权限
final status = await Permission.manageExternalStorage.request();
if (!status.isGranted) {
if (mounted) {
ToastHelper.failure('需要所有文件管理权限才能写入相册');
final shouldOpen = 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),
child: const Text('去设置'),
),
],
),
);
if (shouldOpen == true) {
await openAppSettings();
}
}
return;
}
}
final appSupportDir = await getApplicationSupportDirectory();
final config = SyncConfigModel(
@@ -909,7 +1054,23 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
logLevel: _logLevel,
);
await sync.startSync(config);
// Album 模式:先初始化引擎,再确保远程相册目录存在
if (_syncMode == 'album_upload' || _syncMode == 'album_download') {
await sync.startSync(config);
try {
final result = await SyncService.instance.checkCloudAlbumDirs(
'cloudreve://my',
);
if (!(result['cameraExists'] as bool? ?? false)) {
AppLogger.i('远程 DCIM/Camera 目录不完整,正在创建...');
await SyncService.instance.createCloudAlbumDirs('cloudreve://my');
}
} catch (e) {
AppLogger.w('检查/创建远程相册目录失败: $e');
}
} else {
await sync.startSync(config);
}
}
Future<void> _stopSync(SyncProvider sync) async {
@@ -940,17 +1101,23 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
}
Future<void> _resetSync(SyncProvider sync) async {
final isAndroid = Platform.isAndroid;
final description = isAndroid
? '此操作将:\n\n'
'• 停止当前同步任务\n'
'• 清空同步数据库(任务记录、文件映射)\n\n'
'本地文件不会被删除。重置后需重新点击"开始同步"。'
: '此操作将:\n\n'
'• 停止当前同步任务\n'
'• 清空同步数据库(任务记录、文件映射)\n'
'• 删除本地同步目录中的所有文件(不影响远程)\n\n'
'重置后需重新点击"开始同步"。此操作不可恢复。';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('重置同步'),
content: const Text(
'此操作将:\n\n'
'• 停止当前同步任务\n'
'• 清空同步数据库(任务记录、文件映射)\n'
'• 删除本地同步目录中的所有文件(不影响远程)\n\n'
'重置后需重新点击"开始同步"。此操作不可恢复。',
),
content: Text(description),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
@@ -968,7 +1135,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
);
if (confirmed == true) {
await sync.resetSync();
await sync.resetSync(deleteLocalFiles: !Platform.isAndroid);
if (mounted) ToastHelper.success('同步已重置');
}
}
@@ -1085,13 +1252,25 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
if (mounted) ToastHelper.error('日志文件不存在');
return;
}
final downloadDir = await getDownloadsDirectory();
if (downloadDir == null) {
if (mounted) ToastHelper.error('无法获取下载目录');
return;
final String downloadPath;
if (Platform.isAndroid) {
downloadPath = await ExternalPath.getExternalStoragePublicDirectory(
ExternalPath.DIRECTORY_DOWNLOAD,
);
} else {
final downloadDir = await getDownloadsDirectory();
if (downloadDir == null) {
if (mounted) ToastHelper.error('无法获取下载目录');
return;
}
downloadPath = downloadDir.path;
}
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').substring(0, 19);
final destPath = '${downloadDir.path}${Platform.pathSeparator}sync_core_log_$timestamp.txt';
final timestamp = DateTime.now()
.toIso8601String()
.replaceAll(':', '-')
.substring(0, 19);
final destPath =
'$downloadPath${Platform.pathSeparator}sync_core_log_$timestamp.txt';
await srcFile.copy(destPath);
if (mounted) ToastHelper.success('日志已导出到:$destPath');
} catch (e) {
@@ -1100,11 +1279,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
}
Future<void> _previewSyncLog() async {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const SyncLogViewerPage(),
),
);
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SyncLogViewerPage()));
}
Future<void> _clearSyncLog() async {
@@ -1120,7 +1297,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
child: const Text('清空'),
),
],