diff --git a/.gitignore b/.gitignore index d6ed355..bf0beb4 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ lib/firebase_options.dart native/target native/logs sync_refactory.md +linux-fuse.md tools/* *.diff android/app/src/main/jniLibs/* diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5cf6d6e..da2114f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -81,7 +81,17 @@ android { versionName = flutter.versionName ndk { - abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a")) + // abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a")) + } + } + + splits { + abi { + isEnable = true + reset() + include("armeabi-v7a", "arm64-v8a", "x86_64") + // include("armeabi-v7a", "arm64-v8a") + isUniversalApk = true } } diff --git a/lib/core/constants/sort_options.dart b/lib/core/constants/sort_options.dart new file mode 100644 index 0000000..a59a8a0 --- /dev/null +++ b/lib/core/constants/sort_options.dart @@ -0,0 +1,70 @@ +/// 排序字段 +enum SortField { + name('名称', 'name'), + size('大小', 'size'), + updatedAt('修改时间', 'updated_at'), + createdAt('创建时间', 'created_at'); + + final String label; + final String apiKey; + const SortField(this.label, this.apiKey); +} + +/// 排序方向 +enum SortDirection { + asc('升序', 'asc'), + desc('降序', 'desc'); + + final String label; + final String apiKey; + const SortDirection(this.label, this.apiKey); +} + +/// 排序选项 +class SortOption { + final SortField field; + final SortDirection direction; + + const SortOption(this.field, this.direction); + + static const default_ = SortOption(SortField.name, SortDirection.asc); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SortOption && + field == other.field && + direction == other.direction; + + @override + int get hashCode => Object.hash(field, direction); + + /// 持久化字符串,格式: "name_asc" + String toKey() => '${field.apiKey}_${direction.apiKey}'; + + /// 从持久化字符串恢复 + static SortOption fromKey(String? key) { + if (key == null) return default_; + final parts = key.split('_'); + if (parts.length != 2) return default_; + final field = SortField.values + .where((f) => f.apiKey == parts[0]) + .firstOrNull; + final dir = SortDirection.values + .where((d) => d.apiKey == parts[1]) + .firstOrNull; + if (field == null || dir == null) return default_; + return SortOption(field, dir); + } + + /// 生成菜单项显示文本 + String get menuLabel { + final dirLabel = switch (field) { + SortField.name => direction == SortDirection.asc ? 'A→Z' : 'Z→A', + SortField.size => direction == SortDirection.asc ? '小→大' : '大→小', + SortField.updatedAt => direction == SortDirection.asc ? '旧→新' : '新→旧', + SortField.createdAt => direction == SortDirection.asc ? '旧→新' : '新→旧', + }; + return '${field.label} $dirLabel'; + } +} diff --git a/lib/core/constants/storage_keys.dart b/lib/core/constants/storage_keys.dart index 08873be..ceb6e45 100644 --- a/lib/core/constants/storage_keys.dart +++ b/lib/core/constants/storage_keys.dart @@ -31,8 +31,12 @@ class StorageKeys { // 同步相关 static const String syncConfig = 'sync_config'; static const String syncState = 'sync_state'; + static const String syncCumStats = 'sync_cum_stats'; static const String clientId = 'client_id'; + // 文件排序 + static const String fileSortOption = 'file_sort_option'; + // 日志级别 static const String logLevel = 'app_log_level'; diff --git a/lib/core/constants/sync_defaults.dart b/lib/core/constants/sync_defaults.dart index d342aea..d21ac02 100644 --- a/lib/core/constants/sync_defaults.dart +++ b/lib/core/constants/sync_defaults.dart @@ -1,22 +1,32 @@ import 'dart:io'; +import 'package:external_path/external_path.dart'; class SyncDefaults { SyncDefaults._(); - /// 默认同步目录 + /// 默认同步目录(同步版本,Android 返回空串,需用 getDefaultLocalRoot 异步获取) static String defaultLocalRoot() { if (Platform.isWindows) { return '${Platform.environment['USERPROFILE']}\\Documents\\Cloudreve4'; } else if (Platform.isLinux) { final xdgDownload = - Platform.environment['XDG_DOWNLOAD_DIR'] ?? ("${Platform.environment['HOME'] ?? ''}/Downloads"); + Platform.environment['XDG_DOWNLOAD_DIR'] ?? + ("${Platform.environment['HOME'] ?? ''}/Downloads"); return '$xdgDownload/Cloudreve4'; } else if (Platform.isAndroid) { - return ''; // Android 使用系统相册目录 + return ''; } return ''; } + /// 默认同步目录(异步版本,Android 通过 ExternalPath 获取公共目录) + static Future getDefaultLocalRoot() async { + if (Platform.isAndroid) { + return getDefaultAndroidLocalRoot(); + } + return defaultLocalRoot(); + } + static const String defaultRemoteRoot = 'cloudreve://my'; static const String defaultSyncMode = 'full'; static const String defaultConflictStrategy = 'keep_both'; @@ -24,4 +34,21 @@ class SyncDefaults { static const int defaultBandwidthLimitKbps = 0; static const int defaultMaxWorkers = 0; // 0 = CPU 核心数 static const String defaultLogLevel = 'info'; + + // ===== Android 相册同步专用 ===== + static String get defaultAndroidLocalRoot { + // runtime 获取 DCIM 公共目录,再拼接 /Camera + // ExternalPath 无法 const,使用 getter 延迟求值 + throw UnsupportedError('Use getDefaultAndroidLocalRoot() instead'); + } + + static Future getDefaultAndroidLocalRoot() async { + final dcim = await ExternalPath.getExternalStoragePublicDirectory( + ExternalPath.DIRECTORY_DCIM, + ); + return '$dcim/Camera'; + } + + static const String defaultAndroidRemoteRoot = 'cloudreve://my/DCIM/Camera'; + static const String defaultAndroidSyncMode = 'album_upload'; } diff --git a/lib/data/models/sync_event_model.dart b/lib/data/models/sync_event_model.dart index 8391633..d43d33c 100644 --- a/lib/data/models/sync_event_model.dart +++ b/lib/data/models/sync_event_model.dart @@ -50,6 +50,86 @@ class SyncInitialSyncComplete extends SyncEventModel { SyncInitialSyncComplete(this.summary); } +class SyncWorkerCompleted extends SyncEventModel { + final String taskId; + final int uploaded; + final int downloaded; + final int renamed; + final int moved; + final int failed; + final int durationMs; + SyncWorkerCompleted({ + required this.taskId, + required this.uploaded, + required this.downloaded, + required this.renamed, + required this.moved, + required this.failed, + required this.durationMs, + }); +} + +class SyncWorkerFailed extends SyncEventModel { + final String taskId; + final String message; + SyncWorkerFailed({required this.taskId, required this.message}); +} + +class SyncTaskItemUpdated extends SyncEventModel { + final String taskId; + final String relativePath; + final String action; + final String status; + SyncTaskItemUpdated({ + required this.taskId, + required this.relativePath, + required this.action, + required this.status, + }); +} + +/// 将 FFI 事件转换为 Dart 模型 +SyncEventModel? syncEventFromFfi(ffi.SyncEventFfi event) { + return event.when( + stateChanged: (newState) => SyncStateChanged(newState), + progress: (synced, total, currentFile) => + SyncProgress(synced.toInt(), total.toInt(), currentFile), + fileUploaded: (localPath, remoteUri) => + SyncFileUploaded(localPath, remoteUri), + fileDownloaded: (localPath, remoteUri) => + SyncFileDownloaded(localPath, remoteUri), + conflictDetected: (localPath, conflictType) => + SyncConflictDetected(localPath, conflictType), + error: (message, recoverable) => SyncError(message, recoverable), + tokenExpired: () => SyncTokenExpired(), + diskSpaceWarning: (availableMb) => + SyncDiskSpaceWarning(availableMb.toInt()), + initialSyncComplete: (summary) => + SyncInitialSyncComplete(SyncSummaryModel.fromFfi(summary)), + workerStarted: (taskId, trigger, uploadCount, downloadCount) => null, + workerCompleted: + (taskId, uploaded, downloaded, renamed, moved, failed, durationMs) => + SyncWorkerCompleted( + taskId: taskId, + uploaded: uploaded, + downloaded: downloaded, + renamed: renamed, + moved: moved, + failed: failed, + durationMs: durationMs.toInt(), + ), + workerFailed: (taskId, message) => + SyncWorkerFailed(taskId: taskId, message: message), + taskItemUpdated: (taskId, relativePath, action, status) => + SyncTaskItemUpdated( + taskId: taskId, + relativePath: relativePath, + action: action, + status: status, + ), + ); +} + class SyncSummaryModel { final int uploaded; final int downloaded; diff --git a/lib/presentation/pages/files/category_files_page.dart b/lib/presentation/pages/files/category_files_page.dart index c9f78f7..0c8780b 100644 --- a/lib/presentation/pages/files/category_files_page.dart +++ b/lib/presentation/pages/files/category_files_page.dart @@ -4,10 +4,13 @@ import 'package:lucide_icons/lucide_icons.dart'; import 'package:provider/provider.dart'; import '../../../core/utils/date_utils.dart' as date_utils; +import '../../../core/constants/sort_options.dart'; +import '../../../core/constants/storage_keys.dart'; import '../../../core/utils/file_type_utils.dart'; import '../../../data/models/file_model.dart'; import '../../../router/app_router.dart'; import '../../../services/file_service.dart'; +import '../../../services/storage_service.dart'; import '../../providers/file_manager_provider.dart'; import '../../widgets/file_info_dialog.dart'; import '../../widgets/file_operation_dialogs.dart'; @@ -52,10 +55,7 @@ class CategoryFilesPageArgs { class CategoryFilesPage extends StatefulWidget { final CategoryFilesPageArgs args; - const CategoryFilesPage({ - super.key, - required this.args, - }); + const CategoryFilesPage({super.key, required this.args}); @override State createState() => _CategoryFilesPageState(); @@ -73,6 +73,7 @@ class _CategoryFilesPageState extends State bool _isLoading = true; bool _isLoadingMore = false; String? _errorMessage; + SortOption _sortOption = SortOption.default_; bool get _hasSelection => _selectedFilePaths.isNotEmpty; @@ -83,6 +84,7 @@ class _CategoryFilesPageState extends State @override void initState() { super.initState(); + _restoreSortOption(); _loadFiles(refresh: true); _scrollController.addListener(_onScroll); } @@ -133,11 +135,14 @@ class _CategoryFilesPageState extends State final response = await _fileService.listFilesByCategory( category: widget.args.category, pageSize: 100, + orderBy: _sortOption.field.apiKey, + orderDirection: _sortOption.direction.apiKey, nextPageToken: refresh ? null : _nextPageToken, ); final filesData = response['files'] as List? ?? const []; - final pagination = response['pagination'] as Map? ?? const {}; + final pagination = + response['pagination'] as Map? ?? const {}; final newFiles = filesData .map((item) => FileModel.fromJson(item as Map)) .where((file) => file.isFile) @@ -152,7 +157,9 @@ class _CategoryFilesPageState extends State ..addAll(newFiles); } else { final existingIds = _files.map((e) => e.id).toSet(); - _files.addAll(newFiles.where((file) => !existingIds.contains(file.id))); + _files.addAll( + newFiles.where((file) => !existingIds.contains(file.id)), + ); } _nextPageToken = pagination['next_token'] as String?; @@ -170,8 +177,65 @@ class _CategoryFilesPageState extends State } } + Widget _buildSortMenu() { + final allOptions = [ + for (final field in SortField.values) + for (final dir in SortDirection.values) SortOption(field, dir), + ]; + + return PopupMenuButton( + icon: const Icon(LucideIcons.arrowUpDown), + tooltip: '排序', + position: PopupMenuPosition.under, + onSelected: _setSortOption, + itemBuilder: (context) => allOptions.map((option) { + final isSelected = _sortOption == option; + return PopupMenuItem( + value: option, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelected) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon( + Icons.check, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + const SizedBox(width: 24), + Text(option.menuLabel), + ], + ), + ); + }).toList(), + ); + } + Future _refresh() => _loadFiles(refresh: true); + Future _restoreSortOption() async { + final key = await StorageService.instance.getString( + StorageKeys.fileSortOption, + ); + final option = SortOption.fromKey(key); + if (option != _sortOption) { + setState(() => _sortOption = option); + } + } + + Future _setSortOption(SortOption option) async { + if (_sortOption == option) return; + setState(() => _sortOption = option); + await StorageService.instance.setString( + StorageKeys.fileSortOption, + option.toKey(), + ); + await _loadFiles(refresh: true); + } + void _toggleSelection(FileModel file) { HapticFeedback.selectionClick(); setState(() { @@ -207,10 +271,7 @@ class _CategoryFilesPageState extends State }, child: Scaffold( appBar: _buildAppBar(context), - body: RefreshIndicator( - onRefresh: _refresh, - child: _buildBody(context), - ), + body: RefreshIndicator(onRefresh: _refresh, child: _buildBody(context)), bottomNavigationBar: _buildSelectionBottomBar(context), ), ); @@ -230,10 +291,7 @@ class _CategoryFilesPageState extends State centerTitle: true, title: Text('已选中 ${_selectedFilePaths.length} 个文件'), actions: [ - TextButton( - onPressed: _selectAllVisible, - child: const Text('全选'), - ), + TextButton(onPressed: _selectAllVisible, child: const Text('全选')), ], ); } @@ -241,6 +299,7 @@ class _CategoryFilesPageState extends State return AppBar( title: Text(args.title), actions: [ + _buildSortMenu(), IconButton( icon: const Icon(LucideIcons.refreshCw), tooltip: '刷新', @@ -265,10 +324,7 @@ class _CategoryFilesPageState extends State layoutBuilder: (currentChild, previousChildren) { return Stack( alignment: Alignment.bottomCenter, - children: [ - ...previousChildren, - ?currentChild, - ], + children: [...previousChildren, ?currentChild], ); }, transitionBuilder: (child, animation) { @@ -298,17 +354,17 @@ class _CategoryFilesPageState extends State ? null : () => _showSelectionMore(context, singleSelected), onMove: () => FileOperationDialogs.showBatchMoveDialog( - context, - context.read(), - _selectedFilePaths.toList(), - false, - ), + context, + context.read(), + _selectedFilePaths.toList(), + false, + ), onCopy: () => FileOperationDialogs.showBatchMoveDialog( - context, - context.read(), - _selectedFilePaths.toList(), - true, - ), + context, + context.read(), + _selectedFilePaths.toList(), + true, + ), onDelete: () => _deleteSelectedFiles(context, selected), ) : const SizedBox.shrink( @@ -359,11 +415,7 @@ class _CategoryFilesPageState extends State physics: const AlwaysScrollableScrollPhysics(), children: [ SizedBox(height: MediaQuery.sizeOf(context).height * 0.25), - Icon( - widget.args.icon, - size: 52, - color: widget.args.color, - ), + Icon(widget.args.icon, size: 52, color: widget.args.color), const SizedBox(height: 12), Center( child: Text( @@ -383,7 +435,7 @@ class _CategoryFilesPageState extends State final horizontalPadding = width >= 720 ? 16.0 : 10.0; final columnWidth = (width - horizontalPadding * 2 - spacing * (columnCount - 1)) / - columnCount; + columnCount; final columns = List.generate(columnCount, (_) => []); final heights = List.generate(columnCount, (_) => 0.0); @@ -391,7 +443,8 @@ class _CategoryFilesPageState extends State for (final file in _files) { final targetIndex = _indexOfMin(heights); columns[targetIndex].add(file); - heights[targetIndex] += _estimatedTileHeight(file, columnWidth) + spacing; + heights[targetIndex] += + _estimatedTileHeight(file, columnWidth) + spacing; } return ListView( @@ -417,12 +470,16 @@ class _CategoryFilesPageState extends State Padding( padding: EdgeInsets.only(bottom: spacing), child: _CategoryFileTile( - key: ValueKey('category-file-${file.id.isNotEmpty ? file.id : file.path}'), + key: ValueKey( + 'category-file-${file.id.isNotEmpty ? file.id : file.path}', + ), file: file, contextHint: _contextHint, category: widget.args.category, accentColor: widget.args.color, - isSelected: _selectedFilePaths.contains(file.path), + isSelected: _selectedFilePaths.contains( + file.path, + ), selectionMode: _hasSelection, onTap: () { if (_hasSelection) { @@ -536,11 +593,17 @@ class _CategoryFilesPageState extends State } else if (FileTypeUtils.isAudio(file.name)) { Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file); } else if (FileTypeUtils.isMarkdown(file.name)) { - Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file); + Navigator.of( + context, + ).pushNamed(RouteNames.markdownPreview, arguments: file); } else if (FileTypeUtils.isTextCode(file.name)) { - Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file); + Navigator.of( + context, + ).pushNamed(RouteNames.documentPreview, arguments: file); } else { - ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}'); + ToastHelper.info( + '暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}', + ); } } @@ -760,9 +823,7 @@ class _CategoryFileTile extends StatelessWidget { ), ), if (category == 'video') - const Center( - child: _PlayOverlay(), - ), + const Center(child: _PlayOverlay()), ], ), ), @@ -807,8 +868,9 @@ class _CategoryFileTile extends StatelessWidget { boxShadow: isSelected ? [ BoxShadow( - color: theme.colorScheme.primary - .withValues(alpha: 0.12), + color: theme.colorScheme.primary.withValues( + alpha: 0.12, + ), blurRadius: 10, spreadRadius: 0.5, ), @@ -877,10 +939,7 @@ class _SelectionCircle extends StatelessWidget { final bool selected; final VoidCallback? onTap; - const _SelectionCircle({ - required this.selected, - this.onTap, - }); + const _SelectionCircle({required this.selected, this.onTap}); @override Widget build(BuildContext context) { @@ -915,11 +974,7 @@ class _SelectionCircle extends StatelessWidget { ], ), child: selected - ? const Icon( - LucideIcons.check, - color: Colors.white, - size: 16, - ) + ? const Icon(LucideIcons.check, color: Colors.white, size: 16) : null, ), ), @@ -948,10 +1003,7 @@ class _TypeBadge extends StatelessWidget { borderRadius: BorderRadius.circular(9), ), child: Padding( - padding: EdgeInsets.symmetric( - horizontal: compact ? 6 : 7, - vertical: 4, - ), + padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 7, vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -986,11 +1038,7 @@ class _PlayOverlay extends StatelessWidget { ), child: const Padding( padding: EdgeInsets.all(10), - child: Icon( - LucideIcons.play, - color: Colors.white, - size: 22, - ), + child: Icon(LucideIcons.play, color: Colors.white, size: 22), ), ); } diff --git a/lib/presentation/pages/files/files_page.dart b/lib/presentation/pages/files/files_page.dart index 417fd47..7ae6079 100644 --- a/lib/presentation/pages/files/files_page.dart +++ b/lib/presentation/pages/files/files_page.dart @@ -4,10 +4,12 @@ import 'dart:ui'; import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/services.dart'; import 'package:cloudreve4_flutter/data/models/file_model.dart'; import 'package:cloudreve4_flutter/services/file_service.dart'; import 'package:cloudreve4_flutter/services/upload_service.dart'; import '../../../core/utils/file_utils.dart'; +import '../../../core/constants/sort_options.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:provider/provider.dart'; @@ -41,6 +43,8 @@ class _FilesPageState extends State { bool _isFirstLoad = true; FileModel? _infoFile; final GlobalKey _scaffoldKey = GlobalKey(); + final ScrollController _scrollController = ScrollController(); + final ScrollController _breadcrumbController = ScrollController(); // FAB 状态 bool _isFabVisible = true; @@ -50,24 +54,37 @@ class _FilesPageState extends State { // 桌面端拖拽状态 bool _isDraggingOver = false; + // 滑动手势追踪 + Offset? _swipeStartPos; + DateTime? _swipeStartTime; + @override void initState() { super.initState(); + _scrollController.addListener(_onScrollForPagination); + HardwareKeyboard.instance.addHandler(_handleKeyEvent); Future.delayed(const Duration(milliseconds: 100), () { if (mounted) { - final fileManager = Provider.of(context, listen: false); + final fileManager = Provider.of( + context, + listen: false, + ); final screenWidth = MediaQuery.of(context).size.width; if (screenWidth >= 1000) { fileManager.setViewType(FileViewType.grid); } else { fileManager.setViewType(FileViewType.list); } + fileManager.restoreSortOption(); if (_isFirstLoad) { fileManager.loadFiles(); _isFirstLoad = false; } - final downloadManager = Provider.of(context, listen: false); + final downloadManager = Provider.of( + context, + listen: false, + ); downloadManager.initialize(); } }); @@ -75,8 +92,13 @@ class _FilesPageState extends State { // 上传完成 → 自动刷新当前目录文件列表 UploadService.instance.onUploadCompleted = (targetPath, fileName) { if (!mounted) return; - final fileManager = Provider.of(context, listen: false); - final normalizedCurrent = FileUtils.toCloudreveUri(fileManager.currentPath); + final fileManager = Provider.of( + context, + listen: false, + ); + final normalizedCurrent = FileUtils.toCloudreveUri( + fileManager.currentPath, + ); if (targetPath == normalizedCurrent) { final fileUri = targetPath.endsWith('/') ? '$targetPath$fileName' @@ -88,11 +110,74 @@ class _FilesPageState extends State { @override void dispose() { + _scrollController.removeListener(_onScrollForPagination); + _scrollController.dispose(); + _breadcrumbController.dispose(); _fabShowTimer?.cancel(); + HardwareKeyboard.instance.removeHandler(_handleKeyEvent); UploadService.instance.onUploadCompleted = null; super.dispose(); } + void _onScrollForPagination() { + if (!_scrollController.hasClients) return; + final fileManager = Provider.of( + context, + listen: false, + ); + if (!fileManager.hasMore || + fileManager.isLoadingMore || + fileManager.isLoading) { + return; + } + final position = _scrollController.position; + if (position.pixels >= position.maxScrollExtent - 320) { + fileManager.loadMoreFiles(); + } + } + + bool _handleKeyEvent(KeyEvent event) { + if (!mounted || event is! KeyDownEvent) return false; + // Ctrl+F / Cmd+F → 打开搜索 + if (event.logicalKey == LogicalKeyboardKey.keyF && + (HardwareKeyboard.instance.isControlPressed || + HardwareKeyboard.instance.isMetaPressed)) { + if (ModalRoute.of(context)?.isCurrent == false) return false; + final nav = Provider.of(context, listen: false); + if (nav.currentIndex == 1 && !SearchDialog.isShowing) { + SearchDialog.show(context); + return true; + } + } + return false; + } + + void _onPointerDown(PointerDownEvent event) { + _swipeStartPos = event.position; + _swipeStartTime = DateTime.now(); + } + + void _onPointerUp(PointerUpEvent event) { + if (_swipeStartPos == null || _swipeStartTime == null) return; + final dx = event.position.dx - _swipeStartPos!.dx; + final dy = event.position.dy - _swipeStartPos!.dy; + final duration = DateTime.now().difference(_swipeStartTime!); + _swipeStartPos = null; + _swipeStartTime = null; + // 从右往左快速滑动 → 返回上一级 + if (dx < -150 && + dx.abs() > dy.abs() * 1.5 && + duration.inMilliseconds < 500) { + final fileManager = Provider.of( + context, + listen: false, + ); + if (fileManager.currentPath != '/') { + fileManager.goBack(); + } + } + } + void _showFileInfo(FileModel file) { setState(() => _infoFile = file); // 等待下一帧 rebuild 完成,endDrawer 从 null 变为非 null 后再打开 @@ -101,10 +186,7 @@ class _FilesPageState extends State { }); } - void _showSelectionMore( - FileModel file, - FileManagerProvider fileManager, - ) { + void _showSelectionMore(FileModel file, FileManagerProvider fileManager) { showModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( @@ -116,7 +198,11 @@ class _FilesPageState extends State { title: const Text('重命名'), onTap: () { Navigator.of(sheetContext).pop(); - FileOperationDialogs.showRenameDialog(context, fileManager, file); + FileOperationDialogs.showRenameDialog( + context, + fileManager, + file, + ); }, ), ListTile( @@ -179,13 +265,17 @@ class _FilesPageState extends State { Widget build(BuildContext context) { final isDesktop = MediaQuery.of(context).size.width >= 1000; - return Scaffold( - key: _scaffoldKey, - appBar: _buildAppBar(context), - body: _buildBody(context), - bottomNavigationBar: _buildBottomBar(context), - endDrawer: _infoFile != null ? FileInfoPanel(file: _infoFile!) : null, - floatingActionButton: isDesktop ? null : _buildSpeedDialFAB(context), + return Listener( + onPointerDown: _onPointerDown, + onPointerUp: _onPointerUp, + child: Scaffold( + key: _scaffoldKey, + appBar: _buildAppBar(context), + body: _buildBody(context), + bottomNavigationBar: _buildBottomBar(context), + endDrawer: _infoFile != null ? FileInfoPanel(file: _infoFile!) : null, + floatingActionButton: isDesktop ? null : _buildSpeedDialFAB(context), + ), ); } @@ -200,7 +290,13 @@ class _FilesPageState extends State { if (fileManager.currentPath == '/') { return const Text('文件'); } - return _buildDesktopBreadcrumb(context, fileManager); + final segments = fileManager.currentPath + .split('/') + .where((s) => s.isNotEmpty) + .toList(); + return Text( + segments.isNotEmpty ? _decodePathSegment(segments.last) : '文件', + ); } return _buildMobileBreadcrumb(context, fileManager); }, @@ -224,60 +320,30 @@ class _FilesPageState extends State { return decoded; } - Widget _buildDesktopBreadcrumb(BuildContext context, FileManagerProvider fileManager) { + Widget _buildMobileBreadcrumb( + BuildContext context, + FileManagerProvider fileManager, + ) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final pathParts = fileManager.currentPath.split('/'); pathParts.removeWhere((part) => part.isEmpty); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null, - borderRadius: BorderRadius.circular(6), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Icon(LucideIcons.home, size: 18, color: colorScheme.primary), - ), - ), - for (int i = 0; i < pathParts.length; i++) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)), - ), - InkWell( - onTap: () { - final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}'; - if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath); - }, - borderRadius: BorderRadius.circular(6), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Text( - _decodePathSegment(pathParts[i]), - style: TextStyle( - color: i == pathParts.length - 1 ? colorScheme.onSurface : colorScheme.primary, - fontWeight: i == pathParts.length - 1 ? FontWeight.w600 : FontWeight.normal, - ), - ), - ), - ), - ], - ], - ); - } - - Widget _buildMobileBreadcrumb(BuildContext context, FileManagerProvider fileManager) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final pathParts = fileManager.currentPath.split('/'); - pathParts.removeWhere((part) => part.isEmpty); + // 路径变化后自动滚动到末尾 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_breadcrumbController.hasClients) { + _breadcrumbController.animateTo( + _breadcrumbController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); return SizedBox( height: 40, child: ListView( - key: const PageStorageKey('mobile_breadcrumb'), + controller: _breadcrumbController, scrollDirection: Axis.horizontal, children: [ _buildBreadcrumbChip( @@ -285,12 +351,18 @@ class _FilesPageState extends State { label: '文件', icon: LucideIcons.home, color: colorScheme.primary, - onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null, + onTap: () => fileManager.currentPath != '/' + ? fileManager.enterFolder('/') + : null, ), for (int i = 0; i < pathParts.length; i++) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 2), - child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)), + child: Icon( + LucideIcons.chevronRight, + size: 14, + color: theme.hintColor.withValues(alpha: 0.5), + ), ), _buildBreadcrumbChip( context, @@ -300,7 +372,9 @@ class _FilesPageState extends State { isLast: i == pathParts.length - 1, onTap: () { final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}'; - if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath); + if (targetPath != fileManager.currentPath) { + fileManager.enterFolder(targetPath); + } }, ), ], @@ -324,7 +398,9 @@ class _FilesPageState extends State { height: 28, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( - color: isLast ? color.withValues(alpha: 0.15) : color.withValues(alpha: 0.06), + color: isLast + ? color.withValues(alpha: 0.15) + : color.withValues(alpha: 0.06), borderRadius: BorderRadius.circular(14), ), child: Row( @@ -358,12 +434,19 @@ class _FilesPageState extends State { Consumer( builder: (context, fileManager, child) { return IconButton( - icon: Icon(fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh), + icon: Icon( + fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh, + ), onPressed: () => fileManager.refreshFiles(), tooltip: '刷新', ); }, ), + Consumer( + builder: (context, fileManager, child) { + return _buildSortMenu(fileManager); + }, + ), Consumer( builder: (context, fileManager, child) { final icon = fileManager.viewType == FileViewType.list @@ -378,14 +461,19 @@ class _FilesPageState extends State { : FileViewType.list, ); }, - tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图', + tooltip: fileManager.viewType == FileViewType.list + ? '网格视图' + : '列表视图', ); }, ), IconButton( icon: const Icon(Icons.add), onPressed: () { - final fileManager = Provider.of(context, listen: false); + final fileManager = Provider.of( + context, + listen: false, + ); FileOperationDialogs.showCreateDialog(context, fileManager); }, tooltip: '新建', @@ -397,7 +485,8 @@ class _FilesPageState extends State { ), IconButton( icon: const Icon(Icons.cloud_download), - onPressed: () => Provider.of(context, listen: false).setIndex(2), + onPressed: () => + Provider.of(context, listen: false).setIndex(2), tooltip: '下载', ), ]; @@ -405,6 +494,11 @@ class _FilesPageState extends State { List _buildMobileActions() { return [ + Consumer( + builder: (context, fileManager, child) { + return _buildSortMenu(fileManager); + }, + ), Consumer( builder: (context, fileManager, child) { final icon = fileManager.viewType == FileViewType.list @@ -419,13 +513,52 @@ class _FilesPageState extends State { : FileViewType.list, ); }, - tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图', + tooltip: fileManager.viewType == FileViewType.list + ? '网格视图' + : '列表视图', ); }, ), ]; } + Widget _buildSortMenu(FileManagerProvider fileManager) { + final allOptions = [ + for (final field in SortField.values) + for (final dir in SortDirection.values) SortOption(field, dir), + ]; + + return PopupMenuButton( + icon: const Icon(LucideIcons.arrowUpDown), + tooltip: '排序', + position: PopupMenuPosition.under, + onSelected: (option) => fileManager.setSortOption(option), + itemBuilder: (context) => allOptions.map((option) { + final isSelected = fileManager.sortOption == option; + return PopupMenuItem( + value: option, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isSelected) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon( + Icons.check, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + const SizedBox(width: 24), + Text(option.menuLabel), + ], + ), + ); + }).toList(), + ); + } + // ---- SpeedDial FAB ---- Widget _buildSpeedDialFAB(BuildContext context) { @@ -471,8 +604,16 @@ class _FilesPageState extends State { isDark: isDark, colorScheme: colorScheme, onTap: () { - final fileManager = Provider.of(context, listen: false); - _onFabSubAction(() => FileOperationDialogs.showCreateDialog(context, fileManager)); + final fileManager = Provider.of( + context, + listen: false, + ); + _onFabSubAction( + () => FileOperationDialogs.showCreateDialog( + context, + fileManager, + ), + ); }, ), _buildFabSubItem( @@ -482,7 +623,10 @@ class _FilesPageState extends State { label: '离线下载', isDark: isDark, colorScheme: colorScheme, - onTap: () => _onFabSubAction(() => Navigator.of(context).pushNamed(RouteNames.remoteDownload)), + onTap: () => _onFabSubAction( + () => + Navigator.of(context).pushNamed(RouteNames.remoteDownload), + ), ), Consumer( builder: (context, fileManager, _) { @@ -568,7 +712,10 @@ class _FilesPageState extends State { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 7, + ), decoration: BoxDecoration( color: isDark ? Colors.white.withValues(alpha: 0.12) @@ -649,7 +796,8 @@ class _FilesPageState extends State { final isDesktop = MediaQuery.of(context).size.width >= 1000; final child = _buildFileList(context); - if (!isDesktop || !Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) { + if (!isDesktop || + !Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) { return child; } @@ -673,13 +821,19 @@ class _FilesPageState extends State { strokeAlign: BorderSide.strokeAlignOutside, ), borderRadius: BorderRadius.circular(8), - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85), + color: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.85), ), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(LucideIcons.upload, size: 48, color: Theme.of(context).colorScheme.primary), + Icon( + LucideIcons.upload, + size: 48, + color: Theme.of(context).colorScheme.primary, + ), const SizedBox(height: 12), Text( '释放文件以上传到当前目录', @@ -709,8 +863,14 @@ class _FilesPageState extends State { } if (files.isEmpty) return; - final uploadManager = Provider.of(context, listen: false); - final fileManager = Provider.of(context, listen: false); + final uploadManager = Provider.of( + context, + listen: false, + ); + final fileManager = Provider.of( + context, + listen: false, + ); uploadManager.markShouldShowDialog(); uploadManager.startUpload(files, fileManager.currentPath); ToastHelper.info('已添加 ${files.length} 个文件到上传队列'); @@ -740,12 +900,19 @@ class _FilesPageState extends State { ); } - Widget _buildErrorView(BuildContext context, FileManagerProvider fileManager) { + Widget _buildErrorView( + BuildContext context, + FileManagerProvider fileManager, + ) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error_outline, size: 64, color: Theme.of(context).colorScheme.error), + Icon( + Icons.error_outline, + size: 64, + color: Theme.of(context).colorScheme.error, + ), const SizedBox(height: 16), Text( fileManager.errorMessage!, @@ -783,23 +950,38 @@ class _FilesPageState extends State { Widget _buildListView(BuildContext context, FileManagerProvider fileManager) { final isDesktop = MediaQuery.of(context).size.width >= 1000; final showCheckbox = fileManager.hasSelection; + final itemCount = + fileManager.files.length + + (fileManager.hasMore || fileManager.isLoadingMore ? 1 : 0); return Column( children: [ - if (isDesktop) FileListHeader(showCheckbox: showCheckbox), + if (isDesktop) + FileListHeader( + showCheckbox: showCheckbox, + currentSort: fileManager.sortOption, + onSort: (option) => fileManager.setSortOption(option), + ), Expanded( child: RefreshIndicator( onRefresh: () => _onRefresh(fileManager), child: NotificationListener( onNotification: _onScrollNotification, child: ListView.builder( + controller: _scrollController, key: PageStorageKey('files_list_${fileManager.currentPath}'), cacheExtent: 900, - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - itemCount: fileManager.files.length, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + itemCount: itemCount, itemBuilder: (context, index) { + if (index >= fileManager.files.length) { + return _buildLoadMoreIndicator(context, fileManager); + } final file = fileManager.files[index]; - final isSelected = fileManager.selectedFiles.contains(file.path); + final isSelected = fileManager.selectedFiles.contains( + file.path, + ); return FileListItem( key: ValueKey('file_${file.id}'), @@ -821,14 +1003,40 @@ class _FilesPageState extends State { } }, onSelect: () => fileManager.toggleSelection(file.path), - onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null, - onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null, - onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null, - onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file), - onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false), - onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true), - onShare: () => FileOperationDialogs.showShareDialog(context, file), - onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file), + onDownload: !file.isFolder + ? () => _downloadFile(context, fileManager, file) + : null, + onOpenInBrowser: !file.isFolder + ? () => _openInBrowser(context, file) + : null, + onOpenInCloudreveApp: !file.isFolder + ? () => _openInCloudreveApp(context, file) + : null, + onRename: () => FileOperationDialogs.showRenameDialog( + context, + fileManager, + file, + ), + onMove: () => FileOperationDialogs.showMoveDialog( + context, + fileManager, + file, + false, + ), + onCopy: () => FileOperationDialogs.showMoveDialog( + context, + fileManager, + file, + true, + ), + onShare: () => + FileOperationDialogs.showShareDialog(context, file), + onDelete: () => + FileOperationDialogs.showDeleteSingleConfirmation( + context, + fileManager, + file, + ), onInfo: () => _showFileInfo(file), ); }, @@ -857,15 +1065,20 @@ class _FilesPageState extends State { crossAxisCount = 5; } - final itemWidth = (availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount; + final itemWidth = + (availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount; final childAspectRatio = itemWidth / 160; final showCheckbox = fileManager.hasSelection; + final itemCount = + fileManager.files.length + + (fileManager.hasMore || fileManager.isLoadingMore ? 1 : 0); return RefreshIndicator( onRefresh: () => _onRefresh(fileManager), child: NotificationListener( onNotification: _onScrollNotification, child: GridView.builder( + controller: _scrollController, key: PageStorageKey('files_grid_${fileManager.currentPath}'), cacheExtent: 1100, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, @@ -876,8 +1089,11 @@ class _FilesPageState extends State { crossAxisSpacing: spacing / 2, childAspectRatio: childAspectRatio, ), - itemCount: fileManager.files.length, + itemCount: itemCount, itemBuilder: (context, index) { + if (index >= fileManager.files.length) { + return _buildGridLoadMoreIndicator(context, fileManager); + } final file = fileManager.files[index]; final isSelected = fileManager.selectedFiles.contains(file.path); @@ -900,14 +1116,39 @@ class _FilesPageState extends State { } }, onSelect: () => fileManager.toggleSelection(file.path), - onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null, - onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null, - onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null, - onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file), - onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false), - onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true), - onShare: () => FileOperationDialogs.showShareDialog(context, file), - onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file), + onDownload: !file.isFolder + ? () => _downloadFile(context, fileManager, file) + : null, + onOpenInBrowser: !file.isFolder + ? () => _openInBrowser(context, file) + : null, + onOpenInCloudreveApp: !file.isFolder + ? () => _openInCloudreveApp(context, file) + : null, + onRename: () => FileOperationDialogs.showRenameDialog( + context, + fileManager, + file, + ), + onMove: () => FileOperationDialogs.showMoveDialog( + context, + fileManager, + file, + false, + ), + onCopy: () => FileOperationDialogs.showMoveDialog( + context, + fileManager, + file, + true, + ), + onShare: () => + FileOperationDialogs.showShareDialog(context, file), + onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation( + context, + fileManager, + file, + ), onInfo: () => _showFileInfo(file), ); }, @@ -916,6 +1157,62 @@ class _FilesPageState extends State { ); } + Widget _buildLoadMoreIndicator( + BuildContext context, + FileManagerProvider fileManager, + ) { + if (fileManager.isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: OutlinedButton.icon( + onPressed: () => fileManager.loadMoreFiles(), + icon: const Icon(LucideIcons.chevronsDown, size: 16), + label: const Text('加载更多'), + ), + ), + ); + } + + Widget _buildGridLoadMoreIndicator( + BuildContext context, + FileManagerProvider fileManager, + ) { + if (fileManager.isLoadingMore) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + return Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: OutlinedButton.icon( + onPressed: () => fileManager.loadMoreFiles(), + icon: const Icon(LucideIcons.chevronsDown, size: 16), + label: const Text('加载更多'), + ), + ), + ); + } + Widget _buildBottomBar(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; final isDesktop = screenWidth >= 1000; @@ -930,29 +1227,29 @@ class _FilesPageState extends State { onSelectAll: () => fileManager.selectAll(), onMore: fileManager.selectedFiles.length == 1 ? () => _showSelectionMore( - fileManager.files.firstWhere( - (f) => f.path == fileManager.selectedFiles.first, - ), - fileManager, - ) + fileManager.files.firstWhere( + (f) => f.path == fileManager.selectedFiles.first, + ), + fileManager, + ) : null, onMove: () => FileOperationDialogs.showBatchMoveDialog( - context, - fileManager, - fileManager.selectedFiles, - false, - ), + context, + fileManager, + fileManager.selectedFiles, + false, + ), onCopy: () => FileOperationDialogs.showBatchMoveDialog( - context, - fileManager, - fileManager.selectedFiles, - true, - ), + context, + fileManager, + fileManager.selectedFiles, + true, + ), onDelete: () => FileOperationDialogs.showDeleteConfirmation( - context, - fileManager, - fileManager.selectedFiles, - ), + context, + fileManager, + fileManager.selectedFiles, + ), ); } @@ -976,11 +1273,17 @@ class _FilesPageState extends State { } else if (FileTypeUtils.isAudio(file.name)) { Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file); } else if (FileTypeUtils.isMarkdown(file.name)) { - Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file); + Navigator.of( + context, + ).pushNamed(RouteNames.markdownPreview, arguments: file); } else if (FileTypeUtils.isTextCode(file.name)) { - Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file); + Navigator.of( + context, + ).pushNamed(RouteNames.documentPreview, arguments: file); } else { - ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}'); + ToastHelper.info( + '暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}', + ); } } @@ -989,7 +1292,10 @@ class _FilesPageState extends State { FileManagerProvider fileManager, FileModel file, ) async { - final downloadManager = Provider.of(context, listen: false); + final downloadManager = Provider.of( + context, + listen: false, + ); final task = await downloadManager.addDownloadTask( fileName: file.name, fileUri: file.relativePath, @@ -1037,11 +1343,8 @@ class _FilesPageState extends State { } void _openInCloudreveApp(BuildContext context, FileModel file) { - Navigator.of(context).pushNamed( - RouteNames.cloudreveFileApp, - arguments: { - 'file': file, - }, - ); + Navigator.of( + context, + ).pushNamed(RouteNames.cloudreveFileApp, arguments: {'file': file}); } } diff --git a/lib/presentation/pages/preview/pdf_preview_page.dart b/lib/presentation/pages/preview/pdf_preview_page.dart index 0cad2f8..5b5fc5e 100644 --- a/lib/presentation/pages/preview/pdf_preview_page.dart +++ b/lib/presentation/pages/preview/pdf_preview_page.dart @@ -114,9 +114,12 @@ class _PdfPreviewPageState extends State { initialPageNumber: 1, params: const PdfViewerParams( activeMatchTextColor: Colors.yellow, - annotationRenderingMode: PdfAnnotationRenderingMode.annotationAndForms, - maxScale: 4.0, - minScale: 0.8, // Allow 300% zoom + annotationRenderingMode: + PdfAnnotationRenderingMode.annotationAndForms, + sizeDelegateProvider: PdfViewerSizeDelegateProviderLegacy( + maxScale: 4.0, + minScale: 0.8, + ), scaleEnabled: true, textSelectionParams: PdfTextSelectionParams( enabled: true, diff --git a/lib/presentation/pages/profile/widgets/quick_functions_section.dart b/lib/presentation/pages/profile/widgets/quick_functions_section.dart index e02ebae..47fd1f9 100644 --- a/lib/presentation/pages/profile/widgets/quick_functions_section.dart +++ b/lib/presentation/pages/profile/widgets/quick_functions_section.dart @@ -23,26 +23,48 @@ class QuickFunctionsSection extends StatelessWidget { const QuickFunctionsSection({super.key}); static final _functions = [ - _QuickFunction(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share), - _QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav), - _QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload), - _QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin), + _QuickFunction( + icon: LucideIcons.share2, + label: '我的分享', + route: RouteNames.share, + ), + _QuickFunction( + icon: LucideIcons.cloud, + label: 'WebDAV', + route: RouteNames.webdav, + ), + _QuickFunction( + icon: LucideIcons.download, + label: '离线下载', + route: RouteNames.remoteDownload, + ), + _QuickFunction( + icon: LucideIcons.trash2, + label: '回收站', + route: RouteNames.recycleBin, + ), _QuickFunction( icon: LucideIcons.refreshCw, label: '文件同步', onTap: (ctx) { final nav = ctx.read(); - // 桌面端有同步 Tab(index 4),直接切换;移动端跳转同步设置页 - final isDesktop = defaultTargetPlatform != TargetPlatform.android && + // 桌面端或 Android 平板(宽屏)有同步 Tab,直接切换;手机端跳转同步详情页 + final isDesktop = + defaultTargetPlatform != TargetPlatform.android && defaultTargetPlatform != TargetPlatform.iOS; - if (isDesktop) { - nav.setIndex(4); + final isWideScreen = MediaQuery.of(ctx).size.width >= 800; + if (isDesktop || isWideScreen) { + nav.setIndex(3); } else { - Navigator.of(ctx).pushNamed(RouteNames.syncSettings); + Navigator.of(ctx).pushNamed(RouteNames.syncStatus); } }, ), - _QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings), + _QuickFunction( + icon: LucideIcons.settings, + label: '设置', + route: RouteNames.settings, + ), ]; static const double _spacing = 12; @@ -63,9 +85,12 @@ class QuickFunctionsSection extends StatelessWidget { children: [ Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary), const SizedBox(width: 8), - Text('快捷功能', - style: theme.textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.w600)), + Text( + '快捷功能', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), ], ), ), @@ -79,7 +104,8 @@ class QuickFunctionsSection extends StatelessWidget { if (itemWidth < _minItemWidth) break; perRow = next; } - final itemWidth = (availableWidth - _spacing * (perRow - 1)) / perRow; + final itemWidth = + (availableWidth - _spacing * (perRow - 1)) / perRow; return Wrap( spacing: _spacing, @@ -132,9 +158,7 @@ class _QuickFunctionCardState extends State<_QuickFunctionCard> { final colorScheme = theme.colorScheme; return Card( - color: _hovered - ? colorScheme.surfaceContainerHighest - : null, + color: _hovered ? colorScheme.surfaceContainerHighest : null, child: InkWell( onTap: widget.onTap, borderRadius: BorderRadius.circular(12), diff --git a/lib/presentation/pages/shell/app_shell.dart b/lib/presentation/pages/shell/app_shell.dart index 3a194cb..4e961f1 100644 --- a/lib/presentation/pages/shell/app_shell.dart +++ b/lib/presentation/pages/shell/app_shell.dart @@ -1,9 +1,11 @@ +import 'package:cloudreve4_flutter/presentation/providers/admin_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/download_manager_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/sync_provider.dart'; import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart'; +import 'package:cloudreve4_flutter/presentation/providers/user_setting_provider.dart'; import 'package:cloudreve4_flutter/presentation/widgets/announcement_dialog.dart'; import 'package:cloudreve4_flutter/presentation/widgets/gesture_handler_mixin.dart'; import 'package:cloudreve4_flutter/presentation/widgets/glassmorphism_container.dart'; @@ -57,12 +59,18 @@ class _AppShellState extends State final Set _visitedPageIndexes = {0}; late AnimationController _syncSpinController; String? _lastClipboardShareId; + String? _lastUserId; + bool _cachedShowSyncTab = false; - bool get _showSyncTab => - defaultTargetPlatform != TargetPlatform.android && - defaultTargetPlatform != TargetPlatform.iOS; + static bool _shouldShowSyncTab(double screenWidth) { + if (defaultTargetPlatform != TargetPlatform.android && + defaultTargetPlatform != TargetPlatform.iOS) { + return true; + } + return screenWidth >= 800; + } - List get _pages => _showSyncTab + List _pages(bool showSyncTab) => showSyncTab ? [ const OverviewPage(), const FilesPage(), @@ -78,7 +86,7 @@ class _AppShellState extends State ]; int _clampedIndex(int index) { - final maxIndex = _pages.length - 1; + final maxIndex = _pages(_cachedShowSyncTab).length - 1; if (index < 0) return 0; if (index > maxIndex) return maxIndex; return index; @@ -95,6 +103,7 @@ class _AppShellState extends State WidgetsBinding.instance.addPostFrameCallback((_) { _showPostLoginAnnouncement(); _checkClipboardShareLink(); + _lastUserId = context.read().user?.id; }); } @@ -112,6 +121,56 @@ class _AppShellState extends State } } + void _handleTabSelected(int index) { + final nav = Provider.of(context, listen: false); + nav.setIndex(index); + + final userSetting = Provider.of( + context, + listen: false, + ); + if (index == 0 || index == _pages(_cachedShowSyncTab).length - 1) { + userSetting.loadCapacity(); + } + } + + void _checkUserChange(AuthProvider auth) { + final currentUserId = auth.user?.id; + if (_lastUserId != null && currentUserId != _lastUserId) { + _lastUserId = currentUserId; + Future.microtask(() { + if (mounted) _resetProvidersOnUserChange(); + }); + } else if (_lastUserId == null && currentUserId != null) { + _lastUserId = currentUserId; + } + } + + void _resetProvidersOnUserChange() { + final fileManager = Provider.of( + context, + listen: false, + ); + final userSetting = Provider.of( + context, + listen: false, + ); + final admin = Provider.of(context, listen: false); + final sync = Provider.of(context, listen: false); + + fileManager.clearFiles(); + userSetting.clear(); + admin.clear(); + + if (sync.engineInitialized) { + sync.resetSync(); + } + + final nav = Provider.of(context, listen: false); + nav.setIndex(0); + userSetting.loadCapacity(); + } + Future _showPostLoginAnnouncement() async { final authProvider = context.read(); if (!authProvider.isAuthenticated) return; @@ -193,6 +252,7 @@ class _AppShellState extends State Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; final isDesktop = screenWidth >= 1000; + _cachedShowSyncTab = _shouldShowSyncTab(screenWidth); return PopScope( canPop: false, @@ -217,8 +277,9 @@ class _AppShellState extends State } } }, - child: Consumer( - builder: (context, navProvider, _) { + child: Consumer2( + builder: (context, auth, navProvider, _) { + _checkUserChange(auth); if (isDesktop) { return _buildDesktopLayout(context, navProvider); } @@ -229,7 +290,7 @@ class _AppShellState extends State } Widget _buildPageContent(BuildContext context, int currentIndex) { - final pages = _pages; + final pages = _pages(_cachedShowSyncTab); final visibleIndex = _clampedIndex(currentIndex); _visitedPageIndexes.add(visibleIndex); @@ -297,7 +358,7 @@ class _AppShellState extends State return NavigationBar( height: 64, selectedIndex: _clampedIndex(navProvider.currentIndex), - onDestinationSelected: (i) => navProvider.setIndex(i), + onDestinationSelected: _handleTabSelected, destinations: [ const NavigationDestination( icon: Icon(LucideIcons.layoutDashboard), @@ -322,7 +383,7 @@ class _AppShellState extends State ), label: '任务', ), - if (_showSyncTab) + if (_cachedShowSyncTab) NavigationDestination( icon: Consumer( builder: (context, sync, _) { @@ -373,15 +434,18 @@ class _AppShellState extends State children: [ NavigationRail( selectedIndex: _clampedIndex(navProvider.currentIndex), - onDestinationSelected: (i) => navProvider.setIndex(i), + onDestinationSelected: _handleTabSelected, leading: Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: GestureDetector( - onTap: () => navProvider.setIndex(_pages.length - 1), + onTap: () => + navProvider.setIndex(_pages(_cachedShowSyncTab).length - 1), child: Container( decoration: BoxDecoration( shape: BoxShape.circle, - border: navProvider.currentIndex == _pages.length - 1 + border: + navProvider.currentIndex == + _pages(_cachedShowSyncTab).length - 1 ? Border.all( color: theme.colorScheme.primary, width: 2.5, @@ -424,7 +488,7 @@ class _AppShellState extends State selectedIcon: const Icon(LucideIcons.listChecks, weight: 700), label: const Text('任务'), ), - if (_showSyncTab) + if (_cachedShowSyncTab) NavigationRailDestination( icon: Consumer( builder: (context, sync, _) { diff --git a/lib/presentation/pages/sync/sync_page.dart b/lib/presentation/pages/sync/sync_page.dart index 7ab824c..77731b3 100644 --- a/lib/presentation/pages/sync/sync_page.dart +++ b/lib/presentation/pages/sync/sync_page.dart @@ -5,6 +5,7 @@ import 'package:lucide_icons/lucide_icons.dart'; import '../../../data/models/sync_task_model.dart'; import '../../providers/sync_provider.dart'; +import '../../widgets/sync_stats_card.dart'; import '../../widgets/toast_helper.dart'; import 'sync_settings_page.dart'; @@ -47,191 +48,328 @@ class _SyncPageState extends State { ], ), body: RefreshIndicator( - onRefresh: () async { - final sync = context.read(); - sync.invalidateAllTaskDetails(); - await sync.loadRecentTasks(); - }, - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: [ - _buildStatusCard(sync, theme), - const SizedBox(height: 8), - _buildActiveTasksSection(sync, theme), - const SizedBox(height: 8), - _buildCompletedTasksSection(sync, theme), - const SizedBox(height: 32), - ], - ), - ), - ); - } - - void _navigateToSettings() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SyncSettingsPage()), - ); - } - - Widget _buildStatusCard(SyncProvider sync, ThemeData theme) { - final isActive = sync.isActive; - final isPaused = sync.isPaused; - final hasError = sync.hasError; - - Color statusColor; - IconData statusIcon; - String statusText; - - if (isActive) { - statusColor = theme.colorScheme.primary; - statusIcon = Icons.sync; - statusText = _syncModeLabel(sync); - } else if (isPaused) { - statusColor = Colors.orange; - statusIcon = Icons.pause_circle_outline; - statusText = '已暂停'; - } else if (hasError) { - statusColor = theme.colorScheme.error; - statusIcon = Icons.error_outline; - statusText = '同步错误'; - } else if (sync.state == SyncState.stopped) { - statusColor = theme.disabledColor; - statusIcon = Icons.stop_circle_outlined; - statusText = '已停止'; - } else { - statusColor = theme.disabledColor; - statusIcon = Icons.cloud_off; - statusText = '未启动'; - } - - return Card( - margin: const EdgeInsets.symmetric(horizontal: 5), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + onRefresh: () async { + final sync = context.read(); + sync.invalidateAllTaskDetails(); + await sync.loadRecentTasks(); + }, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 8), children: [ - Row( - children: [ - Icon(statusIcon, color: statusColor, size: 28), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 800; + final statsCard = 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, + ); + + if (isWide) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _buildStatusHeaderCard(sync, theme)), + const SizedBox(width: 8), + Expanded(child: statsCard), + ], + ), + ); + } + return Column( children: [ - Text( - statusText, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - if (sync.errorMessage != null) - Text( - sync.errorMessage!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + _buildStatusHeaderCard(sync, theme), + const SizedBox(height: 8), + statsCard, ], - ), - ), - if (sync.activeWorkerCount > 0) - Badge( - label: Text('${sync.activeWorkerCount}'), - child: Icon(LucideIcons.loader, color: statusColor, size: 24), - ), - ], - ), - if (isActive || isPaused) ...[ - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: sync.totalFiles > 0 ? sync.progress : null, - minHeight: 6, - ), - ), - const SizedBox(height: 8), - Text( - sync.totalFiles > 0 - ? '${sync.syncedFiles} / ${sync.totalFiles} 文件' - : sync.state == SyncState.continuous - ? '持续同步中' - : '正在同步...', - style: theme.textTheme.bodySmall, - ), - ], - if (sync.lastSummary != null) ...[ - const SizedBox(height: 12), - Wrap( - spacing: 12, - runSpacing: 4, - children: [ - _summaryChip(theme, '上传', sync.lastSummary!.uploaded), - _summaryChip(theme, '下载', sync.lastSummary!.downloaded), - _summaryChip(theme, '冲突', sync.lastSummary!.conflicts), - _summaryChip(theme, '失败', sync.lastSummary!.failed), - _summaryChip(theme, '重命名', sync.lastSummary!.renamed), - _summaryChip(theme, '移动', sync.lastSummary!.moved), - ], - ), - ], - const SizedBox(height: 12), - Row( - children: [ - if (!sync.isActive && !sync.isPaused) - Expanded( - child: FilledButton.icon( - onPressed: () => _navigateToSettings(), - icon: const Icon(Icons.play_arrow, size: 18), - label: const Text('开始同步'), - ), - ), - if (sync.isActive) - Expanded( - child: OutlinedButton.icon( - onPressed: () => sync.pause(), - icon: const Icon(Icons.pause, size: 18), - label: const Text('暂停'), - ), - ), - if (sync.isPaused) - Expanded( - child: FilledButton.icon( - onPressed: () => sync.resume(), - icon: const Icon(Icons.play_arrow, size: 18), - label: const Text('恢复'), - ), - ), - if (sync.isActive || sync.isPaused) ...[ - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: () => _stopSync(sync), - icon: const Icon(Icons.stop, size: 18), - label: const Text('停止'), - style: OutlinedButton.styleFrom( - foregroundColor: theme.colorScheme.error, - ), - ), - ), - ], - ], + ); + }, + ), ), + const SizedBox(height: 8), + _buildActiveTasksSection(sync, theme), + const SizedBox(height: 8), + _buildCompletedTasksSection(sync, theme), + const SizedBox(height: 32), ], ), ), ); } - Widget _summaryChip(ThemeData theme, String label, int value) { + void _navigateToSettings() { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SyncSettingsPage())); + } + + Widget _buildStatusHeaderCard(SyncProvider sync, ThemeData theme) { + final isActive = sync.isActive; + final isPaused = sync.isPaused; + final hasError = sync.hasError; + + Color statusColor; + String statusText; + + if (isActive) { + statusColor = theme.colorScheme.primary; + statusText = _syncModeLabel(sync); + } else if (isPaused) { + statusColor = Colors.orange; + statusText = '已暂停'; + } else if (hasError) { + statusColor = theme.colorScheme.error; + statusText = '同步错误'; + } else if (sync.state == SyncState.stopped) { + statusColor = theme.disabledColor; + statusText = '已停止'; + } else { + statusColor = theme.disabledColor; + statusText = '未启动'; + } + + return Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + theme.colorScheme.tertiaryContainer.withValues(alpha: 0.3), + ], + ), + borderRadius: BorderRadius.circular(24), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Text( + statusText, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ), + if (sync.errorMessage != null) ...[ + const SizedBox(height: 4), + Center( + child: Text( + sync.errorMessage!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 96, + height: 96, + child: Stack( + alignment: Alignment.center, + children: [ + if (isActive) + SizedBox( + width: 96, + height: 96, + child: CircularProgressIndicator( + value: sync.activeTotalCount > 0 + ? sync.activeProgress + : null, + strokeWidth: 6, + strokeCap: StrokeCap.round, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + statusColor, + ), + ), + ) + else + SizedBox( + width: 96, + height: 96, + child: CircularProgressIndicator( + value: 0, + strokeWidth: 6, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + statusColor, + ), + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isActive && sync.activeTotalCount > 0) + Text( + '${(sync.activeProgress * 100).toInt()}%', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + Text( + isActive + ? (sync.state == SyncState.continuous + ? '持续同步' + : '同步中') + : isPaused + ? '已暂停' + : hasError + ? '错误' + : '未启动', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + fontSize: 11, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 24), + IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStatRow( + Icons.file_upload_outlined, + '${sync.cumUploaded}', + '已上传', + Colors.blue, + theme, + ), + const SizedBox(height: 8), + _buildStatRow( + Icons.file_download_outlined, + '${sync.cumDownloaded}', + '已下载', + Colors.green, + theme, + ), + const SizedBox(height: 8), + _buildStatRow( + Icons.warning_amber_outlined, + '${sync.cumConflicts}', + '冲突', + Colors.orange, + theme, + ), + ], + ), + ), + ], + ), + if (isActive && sync.currentFile != null) ...[ + const SizedBox(height: 12), + Text( + sync.currentFile!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + if (isActive && sync.activeTotalCount > 0) ...[ + const SizedBox(height: 8), + Text( + '${sync.activeCompletedCount} / ${sync.activeTotalCount} 文件', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), + ), + ], + const SizedBox(height: 16), + Row( + children: [ + if (!sync.isActive && !sync.isPaused) + Expanded( + child: FilledButton.icon( + onPressed: () => _navigateToSettings(), + icon: const Icon(Icons.play_arrow, size: 18), + label: const Text('开始同步'), + ), + ), + if (sync.isActive) + Expanded( + child: OutlinedButton.icon( + onPressed: () => sync.pause(), + icon: const Icon(Icons.pause, size: 18), + label: const Text('暂停'), + ), + ), + if (sync.isPaused) + Expanded( + child: FilledButton.icon( + onPressed: () => sync.resume(), + icon: const Icon(Icons.play_arrow, size: 18), + label: const Text('恢复'), + ), + ), + if (sync.isActive || sync.isPaused) ...[ + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () => _stopSync(sync), + icon: const Icon(Icons.stop, size: 18), + label: const Text('停止'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + ), + ), + ], + ], + ), + ], + ), + ); + } + + Widget _buildStatRow( + IconData icon, + String value, + String label, + Color color, + ThemeData theme, + ) { return Row( - mainAxisSize: MainAxisSize.min, children: [ - Text('$label:', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)), - Text(' $value', style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600)), + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Text( + value, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 4), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + ), ], ); } @@ -262,9 +400,13 @@ class _SyncPageState extends State { Widget _buildCompletedTasksSection(SyncProvider sync, ThemeData theme) { final tasks = sync.recentTasks - .where((t) => - (t.status == 'completed' || t.status == 'failed' || t.status == 'cancelled') && - t.totalCount > 0) + .where( + (t) => + (t.status == 'completed' || + t.status == 'failed' || + t.status == 'cancelled') && + t.totalCount > 0, + ) .toList(); return _buildTaskSection( @@ -308,7 +450,10 @@ class _SyncPageState extends State { child: Padding( padding: const EdgeInsets.all(24), child: Center( - child: Text(emptyText, style: TextStyle(color: theme.hintColor)), + child: Text( + emptyText, + style: TextStyle(color: theme.hintColor), + ), ), ), ) @@ -357,9 +502,15 @@ class _SyncPageState extends State { child: Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), decoration: BoxDecoration( - color: _statusColor(task.status, theme).withValues(alpha: 0.1), + color: _statusColor( + task.status, + theme, + ).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(4), ), child: Text( @@ -396,8 +547,7 @@ class _SyncPageState extends State { ), ), ), - if (isExpanded) - _buildTaskDetailList(task, theme), + if (isExpanded) _buildTaskDetailList(task, theme), ], ), ); @@ -410,7 +560,13 @@ class _SyncPageState extends State { if (_loadingDetails.contains(task.id)) { return const Padding( padding: EdgeInsets.all(16), - child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), ); } @@ -430,10 +586,41 @@ class _SyncPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6), child: Row( children: [ - SizedBox(width: 70, child: Text('操作', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), - Expanded(child: Text('文件名', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), - SizedBox(width: 55, child: Text('状态', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), - SizedBox(width: 110, child: Text('时间', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), + SizedBox( + width: 70, + child: Text( + '操作', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.hintColor, + ), + ), + ), + Expanded( + child: Text( + '文件名', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.hintColor, + ), + ), + ), + SizedBox( + width: 55, + child: Text( + '状态', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.hintColor, + ), + ), + ), + SizedBox( + width: 110, + child: Text( + '时间', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.hintColor, + ), + ), + ), ], ), ), @@ -463,7 +650,10 @@ class _SyncPageState extends State { child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( - color: _actionColor(item.actionType, theme).withValues(alpha: 0.1), + color: _actionColor( + item.actionType, + theme, + ).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(3), ), child: Text( @@ -560,7 +750,8 @@ class _SyncPageState extends State { setState(() => _expandedTasks.add(taskId)); sync.watchTaskDetail(taskId); - if (sync.getCachedTaskDetail(taskId) == null && !_loadingDetails.contains(taskId)) { + if (sync.getCachedTaskDetail(taskId) == null && + !_loadingDetails.contains(taskId)) { setState(() => _loadingDetails.add(taskId)); sync.getTaskDetail(taskId).whenComplete(() { if (mounted) { diff --git a/lib/presentation/pages/sync/sync_page_android.dart b/lib/presentation/pages/sync/sync_page_android.dart new file mode 100644 index 0000000..bb2e00d --- /dev/null +++ b/lib/presentation/pages/sync/sync_page_android.dart @@ -0,0 +1,768 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../data/models/sync_task_model.dart'; +import '../../providers/sync_provider.dart'; +import '../../widgets/sync_stats_card.dart'; +import 'sync_settings_page.dart'; + +/// 移动端同步详情页面 - 展示实时同步状态和任务列表 +class SyncPageAndroid extends StatefulWidget { + const SyncPageAndroid({super.key}); + + @override + State createState() => _SyncPageAndroidState(); +} + +class _SyncPageAndroidState extends State { + final Set _expandedTasks = {}; + final Set _loadingDetails = {}; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadRecentTasks(); + }); + } + + @override + Widget build(BuildContext context) { + final sync = context.watch(); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('文件同步'), + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () => _navigateToSettings(), + tooltip: '同步设置', + ), + ], + ), + body: RefreshIndicator( + onRefresh: () async { + final sync = context.read(); + sync.invalidateAllTaskDetails(); + await sync.loadRecentTasks(); + }, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _buildHeader(sync, theme), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + 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, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 12), + child: Row( + children: [ + Icon( + Icons.sync_outlined, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + '同步任务', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ], + ), + ), + ), + _buildTaskList(sync, theme), + const SliverToBoxAdapter(child: SizedBox(height: 100)), + ], + ), + ), + ); + } + + void _navigateToSettings() { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SyncSettingsPage())); + } + + Widget _buildHeader(SyncProvider sync, ThemeData theme) { + final isActive = sync.isActive; + final isPaused = sync.isPaused; + final hasError = sync.hasError; + + Color statusColor; + String statusText; + + if (isActive) { + statusColor = theme.colorScheme.primary; + statusText = _syncModeLabel(sync); + } else if (isPaused) { + statusColor = Colors.orange; + statusText = '已暂停'; + } else if (hasError) { + statusColor = theme.colorScheme.error; + statusText = '同步错误'; + } else if (sync.state == SyncState.stopped) { + statusColor = theme.disabledColor; + statusText = '已停止'; + } else { + statusColor = theme.disabledColor; + statusText = '未启动'; + } + + return Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + theme.colorScheme.tertiaryContainer.withValues(alpha: 0.3), + ], + ), + borderRadius: BorderRadius.circular(24), + ), + padding: const EdgeInsets.fromLTRB(24, 16, 24, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 顶部:状态文字居中 + Center( + child: Text( + statusText, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: statusColor, + ), + ), + ), + const SizedBox(height: 16), + // 中间:左侧旋转圆 + 右侧统计 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 左侧:旋转圆形指示器 + SizedBox( + width: 96, + height: 96, + child: Stack( + alignment: Alignment.center, + children: [ + if (isActive) + SizedBox( + width: 96, + height: 96, + child: CircularProgressIndicator( + value: sync.activeTotalCount > 0 + ? sync.activeProgress + : null, + strokeWidth: 6, + strokeCap: StrokeCap.round, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + statusColor, + ), + ), + ) + else + SizedBox( + width: 96, + height: 96, + child: CircularProgressIndicator( + value: 0, + strokeWidth: 6, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + statusColor, + ), + ), + ), + // 中心文字 + Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isActive && sync.activeTotalCount > 0) + Text( + '${(sync.activeProgress * 100).toInt()}%', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + Text( + isActive + ? (sync.state == SyncState.continuous + ? '持续同步' + : '同步中') + : isPaused + ? '已暂停' + : hasError + ? '错误' + : '未启动', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + fontSize: 11, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 24), + // 右侧:统计行 + IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStatRow( + Icons.file_upload_outlined, + '${sync.cumUploaded}', + '已上传', + Colors.blue, + theme, + ), + const SizedBox(height: 8), + _buildStatRow( + Icons.file_download_outlined, + '${sync.cumDownloaded}', + '已下载', + Colors.green, + theme, + ), + const SizedBox(height: 8), + _buildStatRow( + Icons.warning_amber_outlined, + '${sync.cumConflicts}', + '冲突', + Colors.orange, + theme, + ), + ], + ), + ), + ], + ), + if (isActive && sync.currentFile != null) ...[ + const SizedBox(height: 12), + Text( + sync.currentFile!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + if (isActive && sync.activeTotalCount > 0) ...[ + const SizedBox(height: 8), + Text( + '${sync.activeCompletedCount} / ${sync.activeTotalCount} 文件', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), + ), + ], + const SizedBox(height: 16), + // 操作按钮 + Row( + children: [ + if (!sync.isActive && !sync.isPaused) + Expanded( + child: FilledButton.icon( + onPressed: () => _navigateToSettings(), + icon: const Icon(Icons.play_arrow, size: 18), + label: const Text('开始同步'), + ), + ), + if (sync.isActive) + Expanded( + child: OutlinedButton.icon( + onPressed: () => sync.pause(), + icon: const Icon(Icons.pause, size: 18), + label: const Text('暂停'), + ), + ), + if (sync.isPaused) + Expanded( + child: FilledButton.icon( + onPressed: () => sync.resume(), + icon: const Icon(Icons.play_arrow, size: 18), + label: const Text('恢复'), + ), + ), + if (sync.isActive || isPaused) ...[ + const SizedBox(width: 8), + Expanded( + child: OutlinedButton.icon( + onPressed: () => _stopSync(sync), + icon: const Icon(Icons.stop, size: 18), + label: const Text('停止'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + ), + ), + ], + ], + ), + ], + ), + ); + } + + Widget _buildStatRow( + IconData icon, + String value, + String label, + Color color, + ThemeData theme, + ) { + return Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Text( + value, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 4), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + ), + ], + ); + } + + Widget _buildTaskList(SyncProvider sync, ThemeData theme) { + // 活跃任务 + 已完成任务 + final activeTasks = sync.activeTasks; + final completedTasks = sync.recentTasks + .where( + (t) => + (t.status == 'completed' || + t.status == 'failed' || + t.status == 'cancelled') && + t.totalCount > 0, + ) + .toList(); + final allTasks = [...activeTasks, ...completedTasks]; + + if (allTasks.isEmpty) { + return SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.cloud_off, size: 48, color: theme.hintColor), + const SizedBox(height: 12), + Text('暂无同步任务', style: TextStyle(color: theme.hintColor)), + ], + ), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildTaskCard(allTasks[index], sync, theme), + childCount: allTasks.length, + ), + ), + ); + } + + Widget _buildTaskCard( + SyncTaskModel task, + SyncProvider sync, + ThemeData theme, + ) { + final isExpanded = _expandedTasks.contains(task.id); + final isRunning = task.status == 'running'; + final isFailed = task.status == 'failed'; + final isCompleted = task.status == 'completed'; + + Color statusColor = switch (task.status) { + 'running' => theme.colorScheme.primary, + 'completed' => Colors.green, + 'failed' => theme.colorScheme.error, + 'cancelled' => theme.hintColor, + _ => theme.hintColor, + }; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + InkWell( + onTap: () => _toggleTaskExpand(task.id), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // 状态图标 + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + isRunning + ? Icons.sync + : isFailed + ? Icons.error_outline + : isCompleted + ? Icons.check_circle_outline + : Icons.cloud_off, + color: statusColor, + size: 20, + ), + ), + const SizedBox(width: 14), + // 任务信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + task.statusLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: statusColor, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Text( + task.triggerLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.hintColor, + ), + ), + ], + ), + const SizedBox(height: 8), + // 进度条 + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: isRunning && task.totalCount > 0 + ? task.progress + : (isCompleted ? 1.0 : null), + minHeight: 4, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation( + statusColor, + ), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${task.completedCount}/${task.totalCount}', + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 11, + ), + ), + if (task.failedCount > 0) + Text( + '失败${task.failedCount}', + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 11, + color: theme.colorScheme.error, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + isExpanded ? Icons.expand_less : Icons.expand_more, + size: 20, + color: theme.hintColor, + ), + ], + ), + ), + ), + if (isExpanded) _buildTaskDetailList(task, sync, theme), + ], + ), + ); + } + + Widget _buildTaskDetailList( + SyncTaskModel task, + SyncProvider sync, + ThemeData theme, + ) { + final items = sync.getCachedTaskDetail(task.id); + + if (_loadingDetails.contains(task.id)) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + + if (items == null || items.isEmpty) { + return Padding( + padding: const EdgeInsets.all(16), + child: Text('暂无任务项', style: TextStyle(color: theme.hintColor)), + ); + } + + final hasMore = sync.hasMoreTaskDetail(task.id); + + return Column( + children: [ + Divider( + height: 1, + indent: 16, + endIndent: 16, + color: theme.dividerColor, + ), + ...items.map((item) => _buildTaskItemTile(item, theme)), + if (hasMore) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: TextButton.icon( + onPressed: () => _loadMoreDetail(task.id), + icon: const Icon(Icons.expand_more, size: 16), + label: const Text('加载更多'), + ), + ), + const SizedBox(height: 4), + ], + ); + } + + Widget _buildTaskItemTile(SyncTaskItemModel item, ThemeData theme) { + Color actionColor = switch (item.actionType) { + 'upload' => Colors.blue, + 'download' => Colors.green, + 'create_placeholder' => Colors.teal, + 'delete_local' || 'delete_remote' => theme.colorScheme.error, + 'rename' || 'move' => Colors.orange, + 'conflict_resolve' => Colors.purple, + _ => theme.colorScheme.primary, + }; + + Color itemStatusColor = switch (item.status) { + 'completed' => Colors.green, + 'failed' => theme.colorScheme.error, + 'running' => theme.colorScheme.primary, + _ => theme.hintColor, + }; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + // 操作类型图标 + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: actionColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + item.actionType == 'upload' + ? Icons.file_upload_outlined + : item.actionType == 'download' + ? Icons.file_download_outlined + : item.actionType == 'delete_local' || + item.actionType == 'delete_remote' + ? Icons.delete_outline + : item.actionType == 'rename' + ? Icons.edit_outlined + : item.actionType == 'move' + ? Icons.drive_file_move_outline + : Icons.sync_outlined, + color: actionColor, + size: 14, + ), + ), + const SizedBox(width: 10), + // 文件名 + 状态 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.filename, + style: theme.textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + if (item.errorMessage != null) + Text( + item.errorMessage!, + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 10, + color: theme.colorScheme.error, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + // 状态标签 + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: itemStatusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.statusLabel, + style: theme.textTheme.bodySmall?.copyWith( + fontSize: 10, + color: itemStatusColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + String _syncModeLabel(SyncProvider sync) { + final mode = sync.persistedConfig?.syncMode ?? 'full'; + return switch (mode) { + 'full' => '全量同步中', + 'upload_only' => '仅上传中', + 'download_only' => '仅下载中', + 'album_upload' => '相册上传中', + 'album_download' => '相册下载中', + 'mirror_wcf' => '镜像同步中', + _ => '同步中', + }; + } + + void _toggleTaskExpand(String taskId) { + final sync = context.read(); + if (_expandedTasks.contains(taskId)) { + setState(() => _expandedTasks.remove(taskId)); + sync.unwatchTaskDetail(taskId); + return; + } + + setState(() => _expandedTasks.add(taskId)); + sync.watchTaskDetail(taskId); + + if (sync.getCachedTaskDetail(taskId) == null && + !_loadingDetails.contains(taskId)) { + setState(() => _loadingDetails.add(taskId)); + sync.getTaskDetail(taskId).whenComplete(() { + if (mounted) { + setState(() => _loadingDetails.remove(taskId)); + } + }); + } + } + + void _loadMoreDetail(String taskId) { + context.read().loadMoreTaskDetail(taskId); + } + + 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(); + } + } +} diff --git a/lib/presentation/pages/sync/sync_settings_page.dart b/lib/presentation/pages/sync/sync_settings_page.dart index b439026..093d6e1 100644 --- a/lib/presentation/pages/sync/sync_settings_page.dart +++ b/lib/presentation/pages/sync/sync_settings_page.dart @@ -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 { _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 { _maxWorkers = config.maxWorkers; _logLevel = config.logLevel; }); + _applyAlbumPaths(); } _loadSyncLogInfo(); }); @@ -109,10 +121,16 @@ class _SyncSettingsPageState extends State { 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( @@ -124,7 +142,7 @@ class _SyncSettingsPageState extends State { }, child: Column( children: [ - if (Platform.isWindows) + if (Platform.isWindows || Platform.isLinux) RadioListTile( title: Row( mainAxisSize: MainAxisSize.min, @@ -132,9 +150,13 @@ class _SyncSettingsPageState extends State { 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 { 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 { ? 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 { ? 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 { _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( + groupValue: _syncMode, + onChanged: (sync.isActive || sync.isPaused) + ? (_) {} + : (v) { + if (v != null) _handleAlbumModeChange(v); + }, + child: Column( + children: [ + RadioListTile( + title: const Text('仅上传'), + subtitle: const Text('备份手机照片到云端'), + value: 'album_upload', + ), + RadioListTile( + title: const Text('仅下载'), + subtitle: const Text('从云端下载照片到手机'), + value: 'album_download', + ), + ], + ), ), ], ), @@ -253,7 +308,10 @@ class _SyncSettingsPageState extends State { 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 { ), 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 { 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 { ), 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 { ), 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 { 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 { } 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 { } 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 { } 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 { 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 _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 _showModeConfirmDialog({ required String title, required String description, @@ -475,10 +569,7 @@ class _SyncSettingsPageState extends State { 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 { 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 { ), ), ], - 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 { ); } - 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 { 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 { 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 { 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( + 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( + 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 { 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 _stopSync(SyncProvider sync) async { @@ -940,17 +1101,23 @@ class _SyncSettingsPageState extends State { } Future _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( 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 { ); if (confirmed == true) { - await sync.resetSync(); + await sync.resetSync(deleteLocalFiles: !Platform.isAndroid); if (mounted) ToastHelper.success('同步已重置'); } } @@ -1085,13 +1252,25 @@ class _SyncSettingsPageState extends State { 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 { } Future _previewSyncLog() async { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const SyncLogViewerPage(), - ), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const SyncLogViewerPage())); } Future _clearSyncLog() async { @@ -1120,7 +1297,9 @@ class _SyncSettingsPageState extends State { ), 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('清空'), ), ], diff --git a/lib/presentation/providers/admin_provider.dart b/lib/presentation/providers/admin_provider.dart index 41879ff..b5b5fa8 100644 --- a/lib/presentation/providers/admin_provider.dart +++ b/lib/presentation/providers/admin_provider.dart @@ -63,10 +63,7 @@ class AdminProvider extends ChangeNotifier { Future loadAll() async { _setState(AdminState.loading); try { - await Future.wait([ - loadGroups(), - loadUsers(), - ]); + await Future.wait([loadGroups(), loadUsers()]); _setState(AdminState.idle); } catch (e) { _errorMessage = e.toString(); @@ -213,6 +210,19 @@ class AdminProvider extends ChangeNotifier { bool isUserSelected(int userId) => _selectedUserIds.contains(userId); + /// 清除管理员数据(切换账号时调用) + void clear() { + _groups = []; + _users = []; + _groupsPagination = null; + _usersPagination = null; + _selectedUserIds.clear(); + _isSelectingUsers = false; + _errorMessage = null; + _state = AdminState.idle; + notifyListeners(); + } + void _setState(AdminState state) { _state = state; notifyListeners(); diff --git a/lib/presentation/providers/file_manager_provider.dart b/lib/presentation/providers/file_manager_provider.dart index 9f77c8b..7ee87ea 100644 --- a/lib/presentation/providers/file_manager_provider.dart +++ b/lib/presentation/providers/file_manager_provider.dart @@ -3,7 +3,10 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../data/models/file_model.dart'; import '../../services/file_service.dart'; +import '../../services/storage_service.dart'; import '../../services/thumbnail_service.dart'; +import '../../core/constants/sort_options.dart'; +import '../../core/constants/storage_keys.dart'; import '../../core/utils/app_logger.dart'; import '../../core/utils/file_utils.dart'; @@ -15,7 +18,11 @@ class RefreshResult { final int added; final int removed; final int updated; - const RefreshResult({required this.added, required this.removed, required this.updated}); + const RefreshResult({ + required this.added, + required this.removed, + required this.updated, + }); bool get isUnchanged => added == 0 && removed == 0 && updated == 0; } @@ -25,8 +32,11 @@ class FileManagerProvider extends ChangeNotifier { List _files = []; List _selectedFiles = []; FileViewType _viewType = FileViewType.list; + SortOption _sortOption = SortOption.default_; bool _isLoading = false; + bool _isLoadingMore = false; bool _hasMore = true; + String? _nextPageToken; String? _errorMessage; String? _contextHint; String? _highlightPath; @@ -36,15 +46,21 @@ class FileManagerProvider extends ChangeNotifier { List get files => _files; List get selectedFiles => _selectedFiles; FileViewType get viewType => _viewType; + SortOption get sortOption => _sortOption; bool get isLoading => _isLoading; + bool get isLoadingMore => _isLoadingMore; bool get hasMore => _hasMore; + String? get nextPageToken => _nextPageToken; String? get errorMessage => _errorMessage; String? get contextHint => _contextHint; bool get hasSelection => _selectedFiles.isNotEmpty; String? get highlightPath => _highlightPath; /// 加载文件列表 - Future loadFiles({bool refresh = false, Duration timeout = const Duration(seconds: 5)}) async { + Future loadFiles({ + bool refresh = false, + Duration timeout = const Duration(seconds: 5), + }) async { if (refresh) { _selectedFiles.clear(); } @@ -52,13 +68,18 @@ class FileManagerProvider extends ChangeNotifier { setState(() { _isLoading = true; _errorMessage = null; + _nextPageToken = null; }); try { - final response = await FileService().listFiles( - uri: _currentPath, - pageSize: 50, - ).timeout(timeout); + final response = await FileService() + .listFiles( + uri: _currentPath, + pageSize: 50, + orderBy: _sortOption.field.apiKey, + orderDirection: _sortOption.direction.apiKey, + ) + .timeout(timeout); final List filesData = response['files'] as List? ?? []; final pagination = response['pagination'] as Map? ?? {}; @@ -67,7 +88,8 @@ class FileManagerProvider extends ChangeNotifier { _files = filesData .map((f) => FileModel.fromJson(f as Map)) .toList(); - _hasMore = pagination['next_token'] != null; + _nextPageToken = pagination['next_token'] as String?; + _hasMore = _nextPageToken != null; _contextHint = response['context_hint'] as String?; }); } on TimeoutException { @@ -87,12 +109,62 @@ class FileManagerProvider extends ChangeNotifier { } } + /// 加载更多文件(分页) + Future loadMoreFiles({ + Duration timeout = const Duration(seconds: 5), + }) async { + if (_isLoadingMore || _nextPageToken == null) return; + + setState(() { + _isLoadingMore = true; + _errorMessage = null; + }); + + try { + final response = await FileService() + .listFiles( + uri: _currentPath, + pageSize: 50, + orderBy: _sortOption.field.apiKey, + orderDirection: _sortOption.direction.apiKey, + nextPageToken: _nextPageToken, + ) + .timeout(timeout); + + final List filesData = response['files'] as List? ?? []; + final pagination = response['pagination'] as Map? ?? {}; + final newFiles = filesData + .map((f) => FileModel.fromJson(f as Map)) + .toList(); + + setState(() { + final existingIds = _files.map((e) => e.id).toSet(); + _files.addAll(newFiles.where((f) => !existingIds.contains(f.id))); + _nextPageToken = pagination['next_token'] as String?; + _hasMore = _nextPageToken != null; + }); + } on TimeoutException { + setState(() { + _errorMessage = '加载更多超时,请重试'; + }); + } catch (e) { + setState(() { + _errorMessage = e.toString(); + }); + } finally { + setState(() { + _isLoadingMore = false; + }); + } + } + /// 进入文件夹 Future enterFolder(String path) async { _currentPath = path; _selectedFiles.clear(); _highlightPath = null; _highlightTimer?.cancel(); + _nextPageToken = null; ThumbnailService.instance.clearAll(); await loadFiles(); } @@ -111,6 +183,7 @@ class FileManagerProvider extends ChangeNotifier { _selectedFiles.clear(); _highlightPath = null; _highlightTimer?.cancel(); + _nextPageToken = null; ThumbnailService.instance.clearAll(); notifyListeners(); await loadFiles(); @@ -144,6 +217,30 @@ class FileManagerProvider extends ChangeNotifier { notifyListeners(); } + /// 设置排序选项并重新加载 + Future setSortOption(SortOption option) async { + if (_sortOption == option) return; + _sortOption = option; + notifyListeners(); + await StorageService.instance.setString( + StorageKeys.fileSortOption, + option.toKey(), + ); + await loadFiles(refresh: true); + } + + /// 从持久化恢复排序偏好 + Future restoreSortOption() async { + final key = await StorageService.instance.getString( + StorageKeys.fileSortOption, + ); + final option = SortOption.fromKey(key); + if (option != _sortOption) { + _sortOption = option; + notifyListeners(); + } + } + /// 设置错误信息 void setErrorMessage(String? message) { _errorMessage = message; @@ -224,7 +321,11 @@ class FileManagerProvider extends ChangeNotifier { } /// 移动文件(增量更新) - Future moveFiles(List uris, String destination, {bool copy = false}) async { + Future moveFiles( + List uris, + String destination, { + bool copy = false, + }) async { try { await FileService().moveFiles(uris: uris, dst: destination, copy: copy); clearSelection(); @@ -253,7 +354,10 @@ class FileManagerProvider extends ChangeNotifier { /// 重命名文件(原地更新,不刷新列表) Future renameFile(String path, String newName) async { try { - final response = await FileService().renameFile(uri: path, newName: newName); + final response = await FileService().renameFile( + uri: path, + newName: newName, + ); if (response.isEmpty) { await loadFiles(); return null; @@ -319,21 +423,29 @@ class FileManagerProvider extends ChangeNotifier { _selectedFiles = []; _currentPath = '/'; _errorMessage = null; + _nextPageToken = null; + _hasMore = true; }); } - /// 智能刷新 - 只更新差异部分 - Future refreshFiles({Duration timeout = const Duration(seconds: 5)}) async { + /// 智能刷新 - 只更新差异部分(仅刷新首页) + Future refreshFiles({ + Duration timeout = const Duration(seconds: 5), + }) async { setState(() { _isLoading = true; _errorMessage = null; }); try { - final response = await FileService().listFiles( - uri: _currentPath, - pageSize: 50, - ).timeout(timeout); + final response = await FileService() + .listFiles( + uri: _currentPath, + pageSize: 50, + orderBy: _sortOption.field.apiKey, + orderDirection: _sortOption.direction.apiKey, + ) + .timeout(timeout); final List filesData = response['files'] as List? ?? []; final newFiles = filesData @@ -378,9 +490,11 @@ class FileManagerProvider extends ChangeNotifier { } } + final pagination = response['pagination'] as Map?; setState(() { _files = updatedFiles; - _hasMore = response['pagination']?['next_token'] != null; + _nextPageToken = pagination?['next_token'] as String?; + _hasMore = _nextPageToken != null; _contextHint = response['context_hint'] as String?; }); diff --git a/lib/presentation/providers/sync_provider.dart b/lib/presentation/providers/sync_provider.dart index d602410..82cb305 100644 --- a/lib/presentation/providers/sync_provider.dart +++ b/lib/presentation/providers/sync_provider.dart @@ -19,7 +19,6 @@ enum SyncState { } class SyncProvider extends ChangeNotifier { - SyncState _state = SyncState.idle; String? _errorMessage; SyncSummaryModel? _lastSummary; @@ -48,6 +47,17 @@ class SyncProvider extends ChangeNotifier { // 持久化的同步配置 SyncConfigModel? _persistedConfig; + // 累积统计(跨所有 Worker 实时汇总,不仅仅是 lastSummary) + int _cumUploaded = 0; + int _cumDownloaded = 0; + int _cumRenamed = 0; + int _cumMoved = 0; + int _cumFailed = 0; + int _cumConflicts = 0; + int _cumDeletedLocal = 0; + int _cumDeletedRemote = 0; + int _cumSkipped = 0; + SyncState get state => _state; String? get errorMessage => _errorMessage; SyncSummaryModel? get lastSummary => _lastSummary; @@ -62,6 +72,70 @@ class SyncProvider extends ChangeNotifier { List get recentTasks => _recentTasks; int get activeWorkerCount => _activeWorkerCount; + /// 累积上传数(所有 Worker 汇总) + int get cumUploaded => _cumUploaded; + + /// 累积下载数(所有 Worker 汇总) + int get cumDownloaded => _cumDownloaded; + + /// 累积重命名数 + int get cumRenamed => _cumRenamed; + + /// 累积移动数 + int get cumMoved => _cumMoved; + + /// 累积失败数 + int get cumFailed => _cumFailed; + + /// 累积冲突数 + int get cumConflicts => _cumConflicts; + + /// 累积删本地数 + int get cumDeletedLocal => _cumDeletedLocal; + + /// 累积删远程数 + int get cumDeletedRemote => _cumDeletedRemote; + + /// 累积跳过数 + int get cumSkipped => _cumSkipped; + + /// 累积总计操作数 + int get cumTotal => + _cumUploaded + + _cumDownloaded + + _cumRenamed + + _cumMoved + + _cumFailed + + _cumConflicts + + _cumDeletedLocal + + _cumDeletedRemote + + _cumSkipped; + + /// 从活跃任务聚合的已完成数 + int get activeCompletedCount { + int sum = 0; + for (final t in _activeTasks) { + sum += t.completedCount; + } + return sum; + } + + /// 从活跃任务聚合的总数 + int get activeTotalCount { + int sum = 0; + for (final t in _activeTasks) { + sum += t.totalCount; + } + return sum; + } + + /// 从活跃任务聚合的进度(0.0~1.0) + double get activeProgress { + final total = activeTotalCount; + if (total == 0) return 0.0; + return activeCompletedCount / total; + } + bool get isActive => _state == SyncState.initializing || _state == SyncState.initialSync || @@ -75,8 +149,7 @@ class SyncProvider extends ChangeNotifier { bool _engineInitialized = false; bool get engineInitialized => _engineInitialized; - double get progress => - _totalFiles > 0 ? _syncedFiles / _totalFiles : 0.0; + double get progress => _totalFiles > 0 ? _syncedFiles / _totalFiles : 0.0; /// 从持久化存储恢复同步配置和状态 Future restoreFromStorage() async { @@ -89,17 +162,25 @@ class SyncProvider extends ChangeNotifier { refreshToken: configMap['refreshToken'] as String? ?? '', localRoot: configMap['localRoot'] as String? ?? '', remoteRoot: configMap['remoteRoot'] as String? ?? 'cloudreve://my', - syncMode: configMap['syncMode'] as String? ?? 'full', - conflictStrategy: configMap['conflictStrategy'] as String? ?? 'keep_both', - wcfDeleteMode: configMap['wcfDeleteMode'] as String? ?? 'wcf_delete_local_only', - maxConcurrentTransfers: configMap['maxConcurrentTransfers'] as int? ?? 3, + // 旧版 'album' 迁移为 'album_upload' + syncMode: (configMap['syncMode'] as String? ?? 'full') == 'album' + ? 'album_upload' + : (configMap['syncMode'] as String? ?? 'full'), + conflictStrategy: + configMap['conflictStrategy'] as String? ?? 'keep_both', + wcfDeleteMode: + configMap['wcfDeleteMode'] as String? ?? 'wcf_delete_local_only', + maxConcurrentTransfers: + configMap['maxConcurrentTransfers'] as int? ?? 3, bandwidthLimitKbps: configMap['bandwidthLimitKbps'] as int? ?? 0, maxWorkers: configMap['maxWorkers'] as int? ?? 0, dataDir: configMap['dataDir'] as String? ?? '', clientId: configMap['clientId'] as String? ?? '', logLevel: configMap['logLevel'] as String? ?? 'info', ); - AppLogger.i('恢复同步配置: 模式=${_persistedConfig!.syncMode}, 冲突=${_persistedConfig!.conflictStrategy}, 并发=${_persistedConfig!.maxConcurrentTransfers}, 带宽=${_persistedConfig!.bandwidthLimitKbps}kbps, maxWorkers=${_persistedConfig!.maxWorkers}'); + AppLogger.i( + '恢复同步配置: 模式=${_persistedConfig!.syncMode}, 冲突=${_persistedConfig!.conflictStrategy}, 并发=${_persistedConfig!.maxConcurrentTransfers}, 带宽=${_persistedConfig!.bandwidthLimitKbps}kbps, maxWorkers=${_persistedConfig!.maxWorkers}', + ); } catch (e) { AppLogger.e('恢复同步配置失败: $e'); } @@ -109,6 +190,23 @@ class SyncProvider extends ChangeNotifier { if (savedState != null && savedState != 'idle' && savedState != 'stopped') { AppLogger.i('恢复同步状态: $savedState'); } + + // 恢复累积统计 + final cumStats = await StorageService.instance.getSyncCumStats(); + if (cumStats != null) { + _cumUploaded = cumStats['uploaded'] ?? 0; + _cumDownloaded = cumStats['downloaded'] ?? 0; + _cumRenamed = cumStats['renamed'] ?? 0; + _cumMoved = cumStats['moved'] ?? 0; + _cumFailed = cumStats['failed'] ?? 0; + _cumConflicts = cumStats['conflicts'] ?? 0; + _cumDeletedLocal = cumStats['deleted_local'] ?? 0; + _cumDeletedRemote = cumStats['deleted_remote'] ?? 0; + _cumSkipped = cumStats['skipped'] ?? 0; + AppLogger.i( + '恢复累积统计: 上传=$_cumUploaded, 下载=$_cumDownloaded, 失败=$_cumFailed, 冲突=$_cumConflicts, 删本地=$_cumDeletedLocal, 删远程=$_cumDeletedRemote, 跳过=$_cumSkipped', + ); + } } /// 保存同步配置到持久化存储 @@ -132,6 +230,47 @@ class SyncProvider extends ChangeNotifier { }); } + /// 持久化累积统计 + Future _persistCumStats() async { + await StorageService.instance.setSyncCumStats({ + 'uploaded': _cumUploaded, + 'downloaded': _cumDownloaded, + 'renamed': _cumRenamed, + 'moved': _cumMoved, + 'failed': _cumFailed, + 'conflicts': _cumConflicts, + 'deleted_local': _cumDeletedLocal, + 'deleted_remote': _cumDeletedRemote, + 'skipped': _cumSkipped, + }); + } + + /// 从 Rust DB 加载累积统计(权威数据源) + /// 取 DB 和内存中的较大值,避免覆盖事件已递增的增量 + Future _loadCumStatsFromDb() async { + try { + final stats = await SyncService.instance.getCumStats(); + _cumUploaded = _max(_cumUploaded, stats['uploaded'] ?? 0); + _cumDownloaded = _max(_cumDownloaded, stats['downloaded'] ?? 0); + _cumRenamed = _max(_cumRenamed, stats['renamed'] ?? 0); + _cumMoved = _max(_cumMoved, stats['moved'] ?? 0); + _cumFailed = _max(_cumFailed, stats['failed'] ?? 0); + _cumConflicts = _max(_cumConflicts, stats['conflicts'] ?? 0); + _cumDeletedLocal = _max(_cumDeletedLocal, stats['deleted_local'] ?? 0); + _cumDeletedRemote = _max(_cumDeletedRemote, stats['deleted_remote'] ?? 0); + _cumSkipped = _max(_cumSkipped, stats['skipped'] ?? 0); + await _persistCumStats(); + AppLogger.i( + '从 DB 校准累积统计: 上传=$_cumUploaded, 下载=$_cumDownloaded, 失败=$_cumFailed, 冲突=$_cumConflicts, 删本地=$_cumDeletedLocal, 删远程=$_cumDeletedRemote, 跳过=$_cumSkipped', + ); + notifyListeners(); + } catch (e) { + AppLogger.e('从 DB 加载累积统计失败: $e'); + } + } + + static int _max(int a, int b) => a > b ? a : b; + /// 持久化同步状态 Future _persistState(SyncState state) async { final stateStr = switch (state) { @@ -153,6 +292,7 @@ class SyncProvider extends ChangeNotifier { _syncedFiles = 0; _totalFiles = 0; _currentFile = null; + // 不再清零 cum — 从 DB 加载权威数据 notifyListeners(); // 确保 clientId 已注入(Dart 生成、持久化、全层共享) @@ -166,6 +306,10 @@ class SyncProvider extends ChangeNotifier { try { await SyncService.instance.init(configWithClientId); _engineInitialized = true; + + // 从 DB 加载累积统计(权威数据源,替代 SharedPreferences) + await _loadCumStatsFromDb(); + _subscribeEvents(); _state = SyncState.initialSync; @@ -176,22 +320,28 @@ class SyncProvider extends ChangeNotifier { _startPolling(); // 启动初始同步(后台运行) - SyncService.instance.startInitialSync().then((summary) async { - _lastSummary = summary; - _state = SyncState.continuous; - await _persistState(SyncState.continuous); - AppLogger.i('初始同步完成'); - notifyListeners(); + SyncService.instance + .startInitialSync() + .then((summary) async { + _lastSummary = summary; + // 不再覆盖 cum — TaskItemUpdated 事件已实时递增 + // 初始同步完成后从 DB 重新校准(避免事件遗漏) + await _loadCumStatsFromDb(); + _state = SyncState.continuous; + await _persistState(SyncState.continuous); + AppLogger.i('初始同步完成'); + notifyListeners(); - // 自动启动持续同步 - SyncService.instance.startContinuousSync(); - }).catchError((e) async { - _state = SyncState.error; - _errorMessage = e.toString(); - await _persistState(SyncState.error); - AppLogger.e('初始同步失败: $e'); - notifyListeners(); - }); + // 自动启动持续同步 + SyncService.instance.startContinuousSync(); + }) + .catchError((e) async { + _state = SyncState.error; + _errorMessage = e.toString(); + await _persistState(SyncState.error); + AppLogger.e('初始同步失败: $e'); + notifyListeners(); + }); } catch (e) { _state = SyncState.error; _errorMessage = e.toString(); @@ -212,7 +362,10 @@ class SyncProvider extends ChangeNotifier { if (_persistedConfig == null) return; final savedState = await StorageService.instance.getSyncState(); - if (savedState == null || savedState == 'idle' || savedState == 'stopped' || savedState == 'error') { + if (savedState == null || + savedState == 'idle' || + savedState == 'stopped' || + savedState == 'error') { return; } @@ -283,6 +436,7 @@ class SyncProvider extends ChangeNotifier { } int _pollErrorCount = 0; + int _pollCount = 0; Future _pollStatus() async { try { @@ -305,7 +459,8 @@ class SyncProvider extends ChangeNotifier { // 轮询活跃任务 + 刷新已完成任务 try { - final newActiveWorkerCount = await SyncService.instance.getActiveWorkerCount(); + final newActiveWorkerCount = await SyncService.instance + .getActiveWorkerCount(); final newActiveTasks = await SyncService.instance.getActiveTasksTyped(); bool changed = newActiveWorkerCount != _activeWorkerCount; @@ -334,6 +489,43 @@ class SyncProvider extends ChangeNotifier { } } catch (_) {} + // 每 5 次轮询从 DB 校准累积统计(兜底,防止事件丢失导致 UI 不同步) + _pollCount++; + if (_pollCount % 5 == 0 && _engineInitialized) { + try { + final stats = await SyncService.instance.getCumStats(); + final dbUploaded = stats['uploaded'] ?? 0; + final dbDownloaded = stats['downloaded'] ?? 0; + final dbRenamed = stats['renamed'] ?? 0; + final dbMoved = stats['moved'] ?? 0; + final dbFailed = stats['failed'] ?? 0; + final dbConflicts = stats['conflicts'] ?? 0; + final dbDeletedLocal = stats['deleted_local'] ?? 0; + final dbDeletedRemote = stats['deleted_remote'] ?? 0; + final dbSkipped = stats['skipped'] ?? 0; + if (dbUploaded != _cumUploaded || + dbDownloaded != _cumDownloaded || + dbRenamed != _cumRenamed || + dbMoved != _cumMoved || + dbFailed != _cumFailed || + dbConflicts != _cumConflicts || + dbDeletedLocal != _cumDeletedLocal || + dbDeletedRemote != _cumDeletedRemote || + dbSkipped != _cumSkipped) { + _cumUploaded = dbUploaded; + _cumDownloaded = dbDownloaded; + _cumRenamed = dbRenamed; + _cumMoved = dbMoved; + _cumFailed = dbFailed; + _cumConflicts = dbConflicts; + _cumDeletedLocal = dbDeletedLocal; + _cumDeletedRemote = dbDeletedRemote; + _cumSkipped = dbSkipped; + await _persistCumStats(); + } + } catch (_) {} + } + notifyListeners(); _adjustPollInterval(); } catch (_) { @@ -439,11 +631,80 @@ class SyncProvider extends ChangeNotifier { _lastSummary = summary; _state = SyncState.continuous; await _persistState(SyncState.continuous); - SyncService.instance.startContinuousSync(); + // 不再在此启动 continuous sync — .then() 回调已启动,避免重复 case SyncDiskSpaceWarning(): AppLogger.w('磁盘空间不足'); + case SyncWorkerCompleted(): + break; + case SyncWorkerFailed(): + break; + case SyncTaskItemUpdated(:final taskId, :final action, :final status): + AppLogger.d( + '[SyncProvider] TaskItemUpdated: taskId=$taskId, action=$action, status=$status', + ); + // 实时更新活跃任务的 completedCount/failedCount + final idx = _activeTasks.indexWhere((t) => t.id == taskId); + if (idx >= 0) { + final old = _activeTasks[idx]; + if (status == 'completed') { + _activeTasks[idx] = SyncTaskModel( + id: old.id, + trigger: old.trigger, + totalCount: old.totalCount, + completedCount: old.completedCount + 1, + failedCount: old.failedCount, + status: old.status, + createdAt: old.createdAt, + updatedAt: old.updatedAt, + finishedAt: old.finishedAt, + ); + } else if (status == 'failed') { + _activeTasks[idx] = SyncTaskModel( + id: old.id, + trigger: old.trigger, + totalCount: old.totalCount, + completedCount: old.completedCount, + failedCount: old.failedCount + 1, + status: old.status, + createdAt: old.createdAt, + updatedAt: old.updatedAt, + finishedAt: old.finishedAt, + ); + } + } + // 实时递增累积计数器 + if (status == 'completed') { + switch (action) { + case 'upload': + _cumUploaded++; + AppLogger.d('[SyncProvider] cumUploaded=$_cumUploaded'); + case 'download': + _cumDownloaded++; + case 'rename': + _cumRenamed++; + case 'move': + _cumMoved++; + case 'conflict_resolve': + _cumConflicts++; + case 'delete_local': + _cumDeletedLocal++; + case 'delete_remote': + _cumDeletedRemote++; + case 'create_placeholder': + break; + default: + AppLogger.w( + '[SyncProvider] TaskItemUpdated: 未知 action=$action', + ); + } + } else if (status == 'skipped') { + _cumSkipped++; + } else if (status == 'failed') { + _cumFailed++; + } } notifyListeners(); + _persistCumStats(); }); } @@ -554,11 +815,11 @@ class SyncProvider extends ChangeNotifier { _adjustPollInterval(); } - /// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 - Future resetSync() async { + /// 重置同步:停止任务 → 清空 DB → (可选)清空本地目录 → 回到初始状态 + Future resetSync({bool deleteLocalFiles = true}) async { // 引擎未初始化时仅清空本地状态 try { - await SyncService.instance.resetSync(); + await SyncService.instance.resetSync(deleteLocalFiles: deleteLocalFiles); } catch (_) {} _state = SyncState.idle; _errorMessage = null; @@ -569,6 +830,16 @@ class SyncProvider extends ChangeNotifier { _downloadingCount = 0; _currentFile = null; _lastSummary = null; + _cumUploaded = 0; + _cumDownloaded = 0; + _cumRenamed = 0; + _cumMoved = 0; + _cumFailed = 0; + _cumConflicts = 0; + _cumDeletedLocal = 0; + _cumDeletedRemote = 0; + _cumSkipped = 0; + _persistCumStats(); _activeTasks = []; _recentTasks = []; _activeWorkerCount = 0; diff --git a/lib/presentation/providers/user_setting_provider.dart b/lib/presentation/providers/user_setting_provider.dart index 2279ae6..5540eff 100644 --- a/lib/presentation/providers/user_setting_provider.dart +++ b/lib/presentation/providers/user_setting_provider.dart @@ -46,10 +46,7 @@ class UserSettingProvider extends ChangeNotifier { /// 同时加载设置和容量 Future loadAll() async { - await Future.wait([ - loadSettings(), - loadCapacity(), - ]); + await Future.wait([loadSettings(), loadCapacity()]); } /// 修改昵称 @@ -244,6 +241,15 @@ class UserSettingProvider extends ChangeNotifier { notifyListeners(); } + /// 清除用户数据(切换账号时调用) + void clear() { + _settings = null; + _capacity = null; + _errorMessage = null; + _state = UserSettingState.idle; + notifyListeners(); + } + void _setState(UserSettingState state) { _state = state; notifyListeners(); diff --git a/lib/presentation/widgets/file_breadcrumb.dart b/lib/presentation/widgets/file_breadcrumb.dart index 67edc18..b3dc4c3 100644 --- a/lib/presentation/widgets/file_breadcrumb.dart +++ b/lib/presentation/widgets/file_breadcrumb.dart @@ -4,7 +4,7 @@ import 'package:lucide_icons/lucide_icons.dart'; import '../../core/utils/file_utils.dart'; /// 面包屑导航组件 -class FileBreadcrumb extends StatelessWidget { +class FileBreadcrumb extends StatefulWidget { final String currentPath; final void Function(String path) onPathTap; @@ -14,9 +14,38 @@ class FileBreadcrumb extends StatelessWidget { required this.onPathTap, }); + @override + State createState() => _FileBreadcrumbState(); +} + +class _FileBreadcrumbState extends State { + final _controller = ScrollController(); + + @override + void didUpdateWidget(covariant FileBreadcrumb oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentPath != widget.currentPath) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_controller.hasClients) { + _controller.animateTo( + _controller.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - final pathParts = currentPath.split('/'); + final pathParts = widget.currentPath.split('/'); pathParts.removeWhere((part) => part.isEmpty); final theme = Theme.of(context); final colorScheme = theme.colorScheme; @@ -34,6 +63,7 @@ class FileBreadcrumb extends StatelessWidget { ), ), child: SingleChildScrollView( + controller: _controller, scrollDirection: Axis.horizontal, child: Row( children: [ @@ -43,7 +73,7 @@ class FileBreadcrumb extends StatelessWidget { path: '/', icon: LucideIcons.home, primaryColor: colorScheme.primary, - onTap: () => onPathTap('/'), + onTap: () => widget.onPathTap('/'), ), for (int i = 0; i < pathParts.length; i++) ...[ Padding( @@ -60,8 +90,9 @@ class FileBreadcrumb extends StatelessWidget { path: '/${pathParts.sublist(0, i + 1).join('/')}', icon: null, primaryColor: colorScheme.primary, - onTap: () => - onPathTap('/${pathParts.sublist(0, i + 1).join('/')}'), + onTap: () => widget.onPathTap( + '/${pathParts.sublist(0, i + 1).join('/')}', + ), ), ], ], diff --git a/lib/presentation/widgets/file_list_header.dart b/lib/presentation/widgets/file_list_header.dart index b0e1d70..f28b630 100644 --- a/lib/presentation/widgets/file_list_header.dart +++ b/lib/presentation/widgets/file_list_header.dart @@ -1,18 +1,23 @@ import 'package:flutter/material.dart'; -/// 文件列表表头(桌面端) +import '../../core/constants/sort_options.dart'; + +/// 文件列表表头(桌面端),支持点击排序 class FileListHeader extends StatelessWidget { final bool showCheckbox; - const FileListHeader({super.key, this.showCheckbox = false}); + final SortOption? currentSort; + final ValueChanged? onSort; + + const FileListHeader({ + super.key, + this.showCheckbox = false, + this.currentSort, + this.onSort, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final style = TextStyle( - color: theme.hintColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ); return Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), @@ -26,11 +31,77 @@ class FileListHeader extends StatelessWidget { if (showCheckbox) const SizedBox(width: 40), // 图标占位 const SizedBox(width: 36 + 16), - Expanded(flex: 5, child: Text('名称', style: style)), - Expanded(flex: 2, child: Text('修改日期', style: style)), - Expanded(flex: 1, child: Text('大小', style: style)), + Expanded( + flex: 5, + child: _buildSortHeader(context, theme, SortField.name, '名称'), + ), + Expanded( + flex: 2, + child: _buildSortHeader( + context, + theme, + SortField.updatedAt, + '修改日期', + ), + ), + Expanded( + flex: 1, + child: _buildSortHeader(context, theme, SortField.size, '大小'), + ), ], ), ); } + + Widget _buildSortHeader( + BuildContext context, + ThemeData theme, + SortField field, + String label, + ) { + final isActive = currentSort?.field == field; + final style = TextStyle( + color: isActive ? theme.colorScheme.primary : theme.hintColor, + fontSize: 12, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, + ); + + return InkWell( + onTap: () => _onHeaderTap(field), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: style), + if (isActive) ...[ + const SizedBox(width: 4), + Icon( + currentSort!.direction == SortDirection.asc + ? Icons.arrow_upward + : Icons.arrow_downward, + size: 14, + color: theme.colorScheme.primary, + ), + ], + ], + ), + ), + ); + } + + void _onHeaderTap(SortField field) { + if (onSort == null) return; + if (currentSort?.field == field) { + // 同一列:切换方向 + final newDir = currentSort!.direction == SortDirection.asc + ? SortDirection.desc + : SortDirection.asc; + onSort!(SortOption(field, newDir)); + } else { + // 新列:默认升序 + onSort!(SortOption(field, SortDirection.asc)); + } + } } diff --git a/lib/presentation/widgets/search_dialog.dart b/lib/presentation/widgets/search_dialog.dart index 1e0ed08..d22ce7d 100644 --- a/lib/presentation/widgets/search_dialog.dart +++ b/lib/presentation/widgets/search_dialog.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:provider/provider.dart'; @@ -18,7 +19,12 @@ import 'glassmorphism_container.dart'; class SearchDialog extends StatefulWidget { const SearchDialog({super.key}); + static bool _isShowing = false; + static bool get isShowing => _isShowing; + static Future show(BuildContext context) { + if (_isShowing) return Future.value(); + _isShowing = true; return showGeneralDialog( context: context, barrierDismissible: true, @@ -41,7 +47,7 @@ class SearchDialog extends StatefulWidget { }, pageBuilder: (context, animation, secondaryAnimation) => const SearchDialog(), - ); + ).whenComplete(() => _isShowing = false); } @override @@ -185,25 +191,40 @@ class _SearchDialogState extends State { final isWide = screenWidth >= 600; final dialogWidth = isWide ? 560.0 : screenWidth - 32.0; - return Center( - child: Container( - width: dialogWidth, - constraints: BoxConstraints(maxHeight: screenHeight * 0.75), - child: GlassmorphismContainer( - borderRadius: 16, - sigmaX: 20, - sigmaY: 20, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Material( - color: Colors.transparent, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildSearchInput(context), - const Divider(height: 1), - Flexible(child: _buildBody(context)), - ], + return Shortcuts( + shortcuts: { + const SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), + }, + child: Actions( + actions: >{ + DismissIntent: CallbackAction( + onInvoke: (_) { + Navigator.of(context).pop(); + return null; + }, + ), + }, + child: Center( + child: Container( + width: dialogWidth, + constraints: BoxConstraints(maxHeight: screenHeight * 0.75), + child: GlassmorphismContainer( + borderRadius: 16, + sigmaX: 20, + sigmaY: 20, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSearchInput(context), + const Divider(height: 1), + Flexible(child: _buildBody(context)), + ], + ), + ), ), ), ), diff --git a/lib/presentation/widgets/sync_stats_card.dart b/lib/presentation/widgets/sync_stats_card.dart new file mode 100644 index 0000000..1243961 --- /dev/null +++ b/lib/presentation/widgets/sync_stats_card.dart @@ -0,0 +1,363 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +/// 同步统计卡片 +/// 宽屏(>=400):左饼图 + 右双列明细(左5右4) +/// 窄屏(<400):上饼图+5指标,下4指标 +class SyncStatsCard extends StatelessWidget { + final int uploaded; + final int downloaded; + final int renamed; + final int moved; + final int conflicts; + final int failed; + final int deletedLocal; + final int deletedRemote; + final int skipped; + + const SyncStatsCard({ + super.key, + required this.uploaded, + required this.downloaded, + required this.renamed, + required this.moved, + required this.conflicts, + required this.failed, + required this.deletedLocal, + required this.deletedRemote, + required this.skipped, + }); + + static const _colorUploaded = Color(0xFF8E5D67); + static const _colorDownloaded = Color(0xFF8BA7DA); + static const _colorRenamed = Color(0xFFD4AF37); + static const _colorMoved = Color(0xFF67B5B1); + static const _colorConflicts = Color(0xFFE69A6A); + static const _colorFailed = Color(0xFFD9534F); + static const _colorDeletedLocal = Color(0xFFE57373); + static const _colorDeletedRemote = Color(0xFFEF9A9A); + static const _colorSkipped = Color(0xFFB0BEC5); + + static const _narrowThreshold = 400.0; + + int get _total => + uploaded + + downloaded + + renamed + + moved + + conflicts + + failed + + deletedLocal + + deletedRemote + + skipped; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final decoration = BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.colorScheme.primaryContainer.withValues(alpha: 0.3), + theme.colorScheme.tertiaryContainer.withValues(alpha: 0.3), + ], + ), + borderRadius: BorderRadius.circular(24), + ); + + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= _narrowThreshold; + + if (isWide) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: decoration, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Text( + '同步数据概览', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + // 饼图 + Expanded( + flex: 4, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + height: 160, + child: PieChart( + PieChartData( + sectionsSpace: 4, + centerSpaceRadius: 42, + sections: _buildSections(), + ), + ), + ), + Text( + '$_total', + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + // 双列明细 + Expanded( + flex: 6, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // 左列 5 项 + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRow( + '上传', + uploaded, + _colorUploaded, + theme, + ), + _buildRow( + '下载', + downloaded, + _colorDownloaded, + theme, + ), + _buildRow('移动', moved, _colorMoved, theme), + _buildRow( + '冲突', + conflicts, + _colorConflicts, + theme, + ), + _buildRow('失败', failed, _colorFailed, theme), + ], + ), + ), + const SizedBox(width: 12), + // 右列 4 项 + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRow('重命名', renamed, _colorRenamed, theme), + _buildRow( + '删本地', + deletedLocal, + _colorDeletedLocal, + theme, + ), + _buildRow( + '删远程', + deletedRemote, + _colorDeletedRemote, + theme, + ), + _buildRow('跳过', skipped, _colorSkipped, theme), + ], + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + // 窄屏:上下布局 + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), + decoration: decoration, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Text( + '同步数据概览', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 12), + // 上半区:饼图 + 5 个主指标 + Row( + children: [ + SizedBox( + width: 120, + height: 120, + child: Stack( + alignment: Alignment.center, + children: [ + PieChart( + PieChartData( + sectionsSpace: 3, + centerSpaceRadius: 32, + sections: _buildSections(), + ), + ), + Text( + '$_total', + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRow('上传', uploaded, _colorUploaded, theme), + _buildRow('下载', downloaded, _colorDownloaded, theme), + _buildRow('移动', moved, _colorMoved, theme), + _buildRow('冲突', conflicts, _colorConflicts, theme), + _buildRow('失败', failed, _colorFailed, theme), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + // 下半区:4 个副指标 + Row( + children: [ + _buildSubItem('重命名', renamed, _colorRenamed, theme), + const SizedBox(width: 16), + _buildSubItem('删本地', deletedLocal, _colorDeletedLocal, theme), + const SizedBox(width: 16), + _buildSubItem( + '删远程', + deletedRemote, + _colorDeletedRemote, + theme, + ), + const SizedBox(width: 16), + _buildSubItem('跳过', skipped, _colorSkipped, theme), + ], + ), + ], + ), + ); + }, + ); + } + + /// 指标行:圆点 + 固定宽标签 + 数字,保证数字对齐 + Widget _buildRow(String label, int count, Color color, ThemeData theme) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3.5), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + SizedBox( + width: 40, + child: Text( + label, + style: TextStyle(fontSize: 12, color: theme.hintColor), + ), + ), + Text( + '$count', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + ], + ), + ); + } + + /// 窄屏副指标:圆点 + 标签 + 数字(紧凑,Expanded 均分) + Widget _buildSubItem(String label, int count, Color color, ThemeData theme) { + return Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + style: TextStyle(fontSize: 11, color: theme.hintColor), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 3), + Text( + '$count', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ], + ), + ); + } + + List _buildSections() { + if (_total == 0) { + return [ + PieChartSectionData( + value: 1, + color: const Color(0xFFE0E0E0), + radius: 14, + showTitle: false, + ), + ]; + } + + final entries = [ + (uploaded, _colorUploaded, 18.0), + (downloaded, _colorDownloaded, 16.0), + (renamed, _colorRenamed, 14.0), + (moved, _colorMoved, 12.0), + (conflicts, _colorConflicts, 10.0), + (failed, _colorFailed, 8.0), + (deletedLocal, _colorDeletedLocal, 8.0), + (deletedRemote, _colorDeletedRemote, 6.0), + (skipped, _colorSkipped, 6.0), + ]; + + return entries + .where((e) => e.$1 > 0) + .map( + (e) => PieChartSectionData( + value: e.$1.toDouble(), + color: e.$2, + radius: e.$3, + showTitle: false, + ), + ) + .toList(); + } +} diff --git a/lib/router/app_router.dart b/lib/router/app_router.dart index c475263..ab3fe6b 100644 --- a/lib/router/app_router.dart +++ b/lib/router/app_router.dart @@ -7,6 +7,7 @@ import '../presentation/pages/recycle_bin/recycle_bin_page.dart'; import '../presentation/pages/webdav/webdav_page.dart'; import '../presentation/pages/remote_download/remote_download_page.dart'; import '../presentation/pages/settings/settings_page.dart'; +import '../presentation/pages/sync/sync_page_android.dart'; import '../presentation/pages/sync/sync_settings_page.dart'; import '../presentation/pages/preview/image_preview_page.dart'; import '../presentation/pages/preview/pdf_preview_page.dart'; @@ -39,6 +40,7 @@ class RouteNames { static const String documentPreview = '/document-preview'; static const String markdownPreview = '/markdown-preview'; static const String categoryFiles = '/category-files'; + static const String syncStatus = '/sync-status'; static const String syncSettings = '/sync-settings'; static const String shareLink = '/share-link'; static const String cloudreveFileApp = '/cloudreve-file-app'; @@ -231,6 +233,12 @@ class AppRouter { builder: (context) => const SyncSettingsPage(), ); + case RouteNames.syncStatus: + return MaterialPageRoute( + settings: settings, + builder: (context) => const SyncPageAndroid(), + ); + case RouteNames.shareLink: final args = settings.arguments; if (args is ShareLinkCandidate) { diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index 1a99a0c..dda14e4 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart' as bd; import 'package:flutter/foundation.dart'; +import 'package:external_path/external_path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import '../core/constants/storage_keys.dart'; @@ -27,7 +28,7 @@ class DownloadService { // 回调处理器 static Function(String taskId, DownloadStatus status, int progress)? - _callbackHandler; + _callbackHandler; final FileService _fileService = FileService(); final Map> _progressControllers = @@ -36,7 +37,8 @@ class DownloadService { /// 设置回调处理器 static void setCallbackHandler( - Function(String taskId, DownloadStatus status, int progress) handler) { + Function(String taskId, DownloadStatus status, int progress) handler, + ) { _callbackHandler = handler; } @@ -65,7 +67,10 @@ class DownloadService { throw Exception('存储权限被拒绝'); } - final directory = Directory('/storage/emulated/0/Download'); + final downloadPath = await ExternalPath.getExternalStoragePublicDirectory( + ExternalPath.DIRECTORY_DOWNLOAD, + ); + final directory = Directory(downloadPath); if (!await directory.exists()) { await directory.create(recursive: true); } @@ -103,22 +108,23 @@ class DownloadService { /// 读取 WiFi-only 下载设置 Future isWifiOnlyEnabled() async { - return await StorageService.instance - .getBool(StorageKeys.downloadWifiOnly) ?? + return await StorageService.instance.getBool( + StorageKeys.downloadWifiOnly, + ) ?? false; } /// 读取重试次数设置 Future getRetries() async { - return await StorageService.instance - .getInt(StorageKeys.downloadRetries) ?? + return await StorageService.instance.getInt(StorageKeys.downloadRetries) ?? 3; } /// 初始化下载器 - Future initialize( - {Function(String taskId, DownloadStatus status, int progress)? - callbackHandler}) async { + Future initialize({ + Function(String taskId, DownloadStatus status, int progress)? + callbackHandler, + }) async { if (callbackHandler != null) { setCallbackHandler(callbackHandler); AppLogger.d('回调处理器已更新'); @@ -133,9 +139,10 @@ class DownloadService { if (Platform.isAndroid) { bd.FileDownloader().configureNotification( running: const bd.TaskNotification( - '正在下载', '文件: {filename} - {progress}'), - complete: - const bd.TaskNotification('下载完成', '文件: {filename} 已保存'), + '正在下载', + '文件: {filename} - {progress}', + ), + complete: const bd.TaskNotification('下载完成', '文件: {filename} 已保存'), error: const bd.TaskNotification('下载失败', '文件: {filename} 下载出错'), paused: const bd.TaskNotification('已暂停', '文件: {filename} 已暂停'), progressBar: true, @@ -170,14 +177,15 @@ class DownloadService { _internalIdToExternalTaskId[metaInternalId] = update.task.taskId; _bdTasks[metaInternalId] = update.task as bd.DownloadTask; AppLogger.d( - '从 metaData 恢复映射: bdTaskId=${update.task.taskId}, internalId=$metaInternalId'); + '从 metaData 恢复映射: bdTaskId=${update.task.taskId}, internalId=$metaInternalId', + ); } - final resolvedInternalId = - _externalTaskIdToInternalId[update.task.taskId]; + final resolvedInternalId = _externalTaskIdToInternalId[update.task.taskId]; if (resolvedInternalId == null) { AppLogger.d( - 'background_downloader 状态回调: 未找到内部任务ID, taskId=${update.task.taskId}'); + 'background_downloader 状态回调: 未找到内部任务ID, taskId=${update.task.taskId}', + ); return; } @@ -201,7 +209,8 @@ class DownloadService { } AppLogger.d( - 'background_downloader 状态更新: taskId=${update.task.taskId}, internalId=$resolvedInternalId, status=$status'); + 'background_downloader 状态更新: taskId=${update.task.taskId}, internalId=$resolvedInternalId, status=$status', + ); final progress = status == DownloadStatus.completed ? 100 : 0; _callbackHandler?.call(resolvedInternalId, status, progress); @@ -261,7 +270,10 @@ class DownloadService { /// 使用 background_downloader 开始下载 Future _startBdDownload( - DownloadTaskModel task, String url, Directory dir) async { + DownloadTaskModel task, + String url, + Directory dir, + ) async { final wifiOnly = await isWifiOnlyEnabled(); final retries = await getRetries(); final bdTask = bd.DownloadTask( @@ -291,14 +303,14 @@ class DownloadService { task.backgroundTaskId = bdTask.taskId; AppLogger.d( - 'background_downloader 任务已添加: taskId=${bdTask.taskId}, internalId=${task.id}, requiresWiFi=$wifiOnly, retries=$retries'); + 'background_downloader 任务已添加: taskId=${bdTask.taskId}, internalId=${task.id}, requiresWiFi=$wifiOnly, retries=$retries', + ); return bdTask.taskId; } /// 恢复下载(用于重启后恢复暂停的任务) - Future resumeDownloadAfterRestart( - DownloadTaskModel task) async { + Future resumeDownloadAfterRestart(DownloadTaskModel task) async { try { if (!_isInitialized) { await initialize(); @@ -349,11 +361,11 @@ class DownloadService { task.backgroundTaskId = bdTask.taskId; // 如果有已下载的部分,尝试 resume;否则 enqueue - final partialFile = - File('${dir.path}/${task.fileName}.part'); + final partialFile = File('${dir.path}/${task.fileName}.part'); if (task.downloadedBytes > 0 && await partialFile.exists()) { AppLogger.d( - '断点续传: ${task.fileName}, 已下载 ${task.downloadedBytes} bytes'); + '断点续传: ${task.fileName}, 已下载 ${task.downloadedBytes} bytes', + ); await bd.FileDownloader().resume(bdTask); } else { AppLogger.d('重新下载: ${task.fileName}'); diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index f7e79f9..909cfd5 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -81,11 +81,13 @@ class StorageService { /// 设置 Future get themeMode => getString(StorageKeys.themeMode); - Future setThemeMode(String value) => setString(StorageKeys.themeMode, value); + Future setThemeMode(String value) => + setString(StorageKeys.themeMode, value); /// 服务器地址配置 Future get customBaseUrl => getString(StorageKeys.customBaseUrl); - Future setCustomBaseUrl(String? value) => setString(StorageKeys.customBaseUrl, value); + Future setCustomBaseUrl(String? value) => + setString(StorageKeys.customBaseUrl, value); Future removeCustomBaseUrl() => remove(StorageKeys.customBaseUrl); /// 服务器列表 @@ -115,8 +117,10 @@ class StorageService { } /// 上次选中的服务器 label - Future get lastSelectedServerLabel => getString(StorageKeys.lastSelectedServer); - Future setLastSelectedServerLabel(String? value) => setString(StorageKeys.lastSelectedServer, value); + Future get lastSelectedServerLabel => + getString(StorageKeys.lastSelectedServer); + Future setLastSelectedServerLabel(String? value) => + setString(StorageKeys.lastSelectedServer, value); /// 搜索历史(最新在前,最多 20 条) Future> getSearchHistory() async { @@ -188,6 +192,25 @@ class StorageService { Future clearSyncData() async { await remove(StorageKeys.syncConfig); await remove(StorageKeys.syncState); + await remove(StorageKeys.syncCumStats); + } + + /// 保存同步累积统计 + Future setSyncCumStats(Map stats) async { + final json = jsonEncode(stats); + return await setString(StorageKeys.syncCumStats, json); + } + + /// 读取同步累积统计 + Future?> getSyncCumStats() async { + final json = await getString(StorageKeys.syncCumStats); + if (json == null) return null; + try { + final map = jsonDecode(json) as Map; + return map.map((k, v) => MapEntry(k, v as int)); + } catch (_) { + return null; + } } // ===== client_id 持久化 ===== diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index d646adb..b840c8a 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -14,6 +14,7 @@ class SyncService { SyncService._(); bool _initialized = false; + StreamSubscription? _rustEventSub; /// 事件流,供 SyncProvider 订阅 final _eventController = StreamController.broadcast(); @@ -22,17 +23,22 @@ class SyncService { /// 初始化同步引擎(已初始化时更新配置) Future init(SyncConfigModel config) async { if (_initialized) { - AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps'); + AppLogger.d( + '[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps', + ); await ffi.updateSyncConfig(config: config.toFfi()); AppLogger.d('[FFI] ← updateSyncConfig: ok'); return; } - AppLogger.d('[FFI] → initSyncEngine: localRoot=${config.localRoot}, mode=${config.syncMode}, conflict=${config.conflictStrategy}, logLevel=${config.logLevel}'); + AppLogger.d( + '[FFI] → initSyncEngine: localRoot=${config.localRoot}, mode=${config.syncMode}, conflict=${config.conflictStrategy}, logLevel=${config.logLevel}', + ); await ffi.initSyncEngine(config: config.toFfi()); _initialized = true; + _subscribeRustEvents(); AppLogger.d('[FFI] ← initSyncEngine: ok'); } @@ -40,7 +46,9 @@ class SyncService { Future startInitialSync() async { AppLogger.d('[FFI] → startInitialSync'); final summary = await ffi.startInitialSync(); - AppLogger.d('[FFI] ← startInitialSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}'); + AppLogger.d( + '[FFI] ← startInitialSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}', + ); return SyncSummaryModel.fromFfi(summary); } @@ -76,7 +84,9 @@ class SyncService { Future forceSync() async { AppLogger.d('[FFI] → forceSync'); final summary = await ffi.forceSync(); - AppLogger.d('[FFI] ← forceSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}'); + AppLogger.d( + '[FFI] ← forceSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}', + ); return SyncSummaryModel.fromFfi(summary); } @@ -90,7 +100,9 @@ class SyncService { /// 更新同步配置(推送到 Rust 引擎,引擎未初始化时忽略) Future updateConfig(SyncConfigModel config) async { if (!_initialized) return; - AppLogger.d('[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps'); + AppLogger.d( + '[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps', + ); try { await ffi.updateSyncConfig(config: config.toFfi()); AppLogger.d('[FFI] ← updateSyncConfig: ok'); @@ -104,7 +116,9 @@ class SyncService { /// 获取同步状态快照(轮询高频调用,trace 级别) Future getStatus() async { final status = await ffi.getSyncStatus(); - AppLogger.t('[FFI] ← getSyncStatus: state=${status.state}, synced=${status.syncedFiles}, total=${status.totalFiles}'); + AppLogger.t( + '[FFI] ← getSyncStatus: state=${status.state}, synced=${status.syncedFiles}, total=${status.totalFiles}', + ); return SyncStatusModel.fromFfi(status); } @@ -119,17 +133,21 @@ class SyncService { Future> getActiveTasksTyped() async { final tasks = await ffi.getActiveTasks(); AppLogger.t('[FFI] ← getActiveTasks: count=${tasks.length}'); - return tasks.map((t) => SyncTaskModel( - id: t.id, - trigger: t.trigger, - totalCount: t.totalCount, - completedCount: t.completedCount, - failedCount: t.failedCount, - status: t.status, - createdAt: t.createdAt, - updatedAt: t.updatedAt, - finishedAt: t.finishedAt, - )).toList(); + return tasks + .map( + (t) => SyncTaskModel( + id: t.id, + trigger: t.trigger, + totalCount: t.totalCount, + completedCount: t.completedCount, + failedCount: t.failedCount, + status: t.status, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + finishedAt: t.finishedAt, + ), + ) + .toList(); } /// 获取最近同步任务列表(轮询高频调用,trace 级别) @@ -137,17 +155,21 @@ class SyncService { AppLogger.t('[FFI] → getRecentTasks: limit=$limit'); final tasks = await ffi.getRecentTasks(limit: limit); AppLogger.t('[FFI] ← getRecentTasks: count=${tasks.length}'); - return tasks.map((t) => SyncTaskModel( - id: t.id, - trigger: t.trigger, - totalCount: t.totalCount, - completedCount: t.completedCount, - failedCount: t.failedCount, - status: t.status, - createdAt: t.createdAt, - updatedAt: t.updatedAt, - finishedAt: t.finishedAt, - )).toList(); + return tasks + .map( + (t) => SyncTaskModel( + id: t.id, + trigger: t.trigger, + totalCount: t.totalCount, + completedCount: t.completedCount, + failedCount: t.failedCount, + status: t.status, + createdAt: t.createdAt, + updatedAt: t.updatedAt, + finishedAt: t.finishedAt, + ), + ) + .toList(); } /// 获取任务详情(按需查询,trace 级别) @@ -155,17 +177,21 @@ class SyncService { AppLogger.t('[FFI] → getTaskDetail: taskId=$taskId'); final items = await ffi.getTaskDetail(taskId: taskId); AppLogger.t('[FFI] ← getTaskDetail: count=${items.length}'); - return items.map((i) => SyncTaskItemModel( - id: i.id.toInt(), - taskId: i.taskId, - relativePath: i.relativePath, - actionType: i.actionType, - status: i.status, - fileSize: i.fileSize.toInt(), - errorMessage: i.errorMessage, - createdAt: i.createdAt, - updatedAt: i.updatedAt, - )).toList(); + return items + .map( + (i) => SyncTaskItemModel( + id: i.id.toInt(), + taskId: i.taskId, + relativePath: i.relativePath, + actionType: i.actionType, + status: i.status, + fileSize: i.fileSize.toInt(), + errorMessage: i.errorMessage, + createdAt: i.createdAt, + updatedAt: i.updatedAt, + ), + ) + .toList(); } /// 分页查询任务详情(trace 级别) @@ -174,24 +200,52 @@ class SyncService { int limit = 20, int offset = 0, }) async { - AppLogger.t('[FFI] → queryTaskItems: taskId=$taskId, limit=$limit, offset=$offset'); - final items = await ffi.queryTaskItems(filter: ffi_types.TaskItemFilterFfi( - taskId: taskId, - limit: limit, - offset: offset, - )); + AppLogger.t( + '[FFI] → queryTaskItems: taskId=$taskId, limit=$limit, offset=$offset', + ); + final items = await ffi.queryTaskItems( + filter: ffi_types.TaskItemFilterFfi( + taskId: taskId, + limit: limit, + offset: offset, + ), + ); AppLogger.t('[FFI] ← queryTaskItems: count=${items.length}'); - return items.map((i) => SyncTaskItemModel( - id: i.id.toInt(), - taskId: i.taskId, - relativePath: i.relativePath, - actionType: i.actionType, - status: i.status, - fileSize: i.fileSize.toInt(), - errorMessage: i.errorMessage, - createdAt: i.createdAt, - updatedAt: i.updatedAt, - )).toList(); + return items + .map( + (i) => SyncTaskItemModel( + id: i.id.toInt(), + taskId: i.taskId, + relativePath: i.relativePath, + actionType: i.actionType, + status: i.status, + fileSize: i.fileSize.toInt(), + errorMessage: i.errorMessage, + createdAt: i.createdAt, + updatedAt: i.updatedAt, + ), + ) + .toList(); + } + + /// 从 DB 聚合累积统计(轮询高频调用,trace 级别) + Future> getCumStats() async { + AppLogger.t('[FFI] → getSyncCumStats'); + final stats = await ffi.getSyncCumStats(); + AppLogger.t( + '[FFI] ← getSyncCumStats: uploaded=${stats.uploaded}, downloaded=${stats.downloaded}, failed=${stats.failed}, conflicts=${stats.conflicts}, deletedLocal=${stats.deletedLocal}, deletedRemote=${stats.deletedRemote}, skipped=${stats.skipped}', + ); + return { + 'uploaded': stats.uploaded, + 'downloaded': stats.downloaded, + 'renamed': stats.renamed, + 'moved': stats.moved, + 'failed': stats.failed, + 'conflicts': stats.conflicts, + 'deleted_local': stats.deletedLocal, + 'deleted_remote': stats.deletedRemote, + 'skipped': stats.skipped, + }; } // ========== 以下为低频操作,保持 debug 级别 ========== @@ -207,12 +261,16 @@ class SyncService { Future> checkCloudAlbumDirs(String baseUri) async { AppLogger.d('[FFI] → checkCloudAlbumDirs: uri=$baseUri'); final result = await ffi.checkCloudAlbumDirs(baseUri: baseUri); - AppLogger.d('[FFI] ← checkCloudAlbumDirs: dcim=${result.dcimExists}, pictures=${result.picturesExists}'); + AppLogger.d( + '[FFI] ← checkCloudAlbumDirs: dcim=${result.dcimExists}, pictures=${result.picturesExists}, camera=${result.cameraExists}', + ); return { 'dcimExists': result.dcimExists, 'picturesExists': result.picturesExists, 'dcimUri': result.dcimUri, 'picturesUri': result.picturesUri, + 'cameraExists': result.cameraExists, + 'cameraUri': result.cameraUri, }; } @@ -226,12 +284,35 @@ class SyncService { /// 销毁同步引擎 Future dispose() async { AppLogger.d('[FFI] → disposeSyncEngine'); + _rustEventSub?.cancel(); + _rustEventSub = null; await _eventController.close(); await ffi.disposeSyncEngine(); _initialized = false; AppLogger.d('[FFI] ← disposeSyncEngine: ok'); } + /// 订阅 Rust 事件流,转换后转发到 _eventController + void _subscribeRustEvents() { + _rustEventSub?.cancel(); + try { + final stream = ffi.registerSyncEventSink(); + _rustEventSub = stream.listen( + (event) { + final model = syncEventFromFfi(event); + if (model != null && !_eventController.isClosed) { + _eventController.add(model); + } + }, + onError: (e) => AppLogger.e('[FFI] Rust event stream error: $e'), + onDone: () => AppLogger.d('[FFI] Rust event stream done'), + ); + AppLogger.d('[FFI] Rust event stream subscribed'); + } catch (e) { + AppLogger.e('[FFI] Failed to subscribe Rust event stream: $e'); + } + } + /// 热修改日志级别(立即生效,无需重启) Future setLogLevel(String level) async { AppLogger.d('[FFI] → setSyncLogLevel: level=$level'); @@ -240,9 +321,9 @@ class SyncService { } /// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 - Future resetSync() async { - AppLogger.d('[FFI] → resetSync'); - await ffi.resetSync(); + Future resetSync({bool deleteLocalFiles = true}) async { + AppLogger.d('[FFI] → resetSync: deleteLocalFiles=$deleteLocalFiles'); + await ffi.resetSync(deleteLocalFiles: deleteLocalFiles); AppLogger.d('[FFI] ← resetSync: ok'); } } diff --git a/lib/src/rust/api/ffi.dart b/lib/src/rust/api/ffi.dart index 31202e9..dce0c33 100644 --- a/lib/src/rust/api/ffi.dart +++ b/lib/src/rust/api/ffi.dart @@ -43,8 +43,11 @@ Future resumeSync() => RustSyncApi.instance.api.crateApiFfiResumeSync(); Future forceSync() => RustSyncApi.instance.api.crateApiFfiForceSync(); -/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 -Future resetSync() => RustSyncApi.instance.api.crateApiFfiResetSync(); +/// 重置同步:停止任务 → 清空 DB → (可选)清空本地目录 → 回到初始状态 +Future resetSync({required bool deleteLocalFiles}) => RustSyncApi + .instance + .api + .crateApiFfiResetSync(deleteLocalFiles: deleteLocalFiles); /// 获取同步状态快照 Future getSyncStatus() => @@ -112,3 +115,7 @@ Future> getTaskDetail({required String taskId}) => Future> queryTaskItems({ required TaskItemFilterFfi filter, }) => RustSyncApi.instance.api.crateApiFfiQueryTaskItems(filter: filter); + +/// 获取累积统计(从 DB 聚合,跨所有同步任务) +Future getSyncCumStats() => + RustSyncApi.instance.api.crateApiFfiGetSyncCumStats(); diff --git a/lib/src/rust/api/ffi_types.dart b/lib/src/rust/api/ffi_types.dart index 2fc0861..9114dd9 100644 --- a/lib/src/rust/api/ffi_types.dart +++ b/lib/src/rust/api/ffi_types.dart @@ -8,7 +8,7 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:freezed_annotation/freezed_annotation.dart' hide protected; part 'ffi_types.freezed.dart'; -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt` /// Android: 云端相册目录检查结果 class CloudAlbumCheckResultFfi { @@ -16,12 +16,16 @@ class CloudAlbumCheckResultFfi { final bool picturesExists; final String? dcimUri; final String? picturesUri; + final bool cameraExists; + final String? cameraUri; const CloudAlbumCheckResultFfi({ required this.dcimExists, required this.picturesExists, this.dcimUri, this.picturesUri, + required this.cameraExists, + this.cameraUri, }); @override @@ -29,7 +33,9 @@ class CloudAlbumCheckResultFfi { dcimExists.hashCode ^ picturesExists.hashCode ^ dcimUri.hashCode ^ - picturesUri.hashCode; + picturesUri.hashCode ^ + cameraExists.hashCode ^ + cameraUri.hashCode; @override bool operator ==(Object other) => @@ -39,7 +45,9 @@ class CloudAlbumCheckResultFfi { dcimExists == other.dcimExists && picturesExists == other.picturesExists && dcimUri == other.dcimUri && - picturesUri == other.picturesUri; + picturesUri == other.picturesUri && + cameraExists == other.cameraExists && + cameraUri == other.cameraUri; } /// 同步配置 @@ -118,6 +126,58 @@ class SyncConfigFfi { logLevel == other.logLevel; } +/// 累积统计(FFI) +class SyncCumStatsFfi { + final int uploaded; + final int downloaded; + final int renamed; + final int moved; + final int failed; + final int conflicts; + final int deletedLocal; + final int deletedRemote; + final int skipped; + + const SyncCumStatsFfi({ + required this.uploaded, + required this.downloaded, + required this.renamed, + required this.moved, + required this.failed, + required this.conflicts, + required this.deletedLocal, + required this.deletedRemote, + required this.skipped, + }); + + @override + int get hashCode => + uploaded.hashCode ^ + downloaded.hashCode ^ + renamed.hashCode ^ + moved.hashCode ^ + failed.hashCode ^ + conflicts.hashCode ^ + deletedLocal.hashCode ^ + deletedRemote.hashCode ^ + skipped.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SyncCumStatsFfi && + runtimeType == other.runtimeType && + uploaded == other.uploaded && + downloaded == other.downloaded && + renamed == other.renamed && + moved == other.moved && + failed == other.failed && + conflicts == other.conflicts && + deletedLocal == other.deletedLocal && + deletedRemote == other.deletedRemote && + skipped == other.skipped; +} + @freezed sealed class SyncErrorFfi with _$SyncErrorFfi implements FrbException { const SyncErrorFfi._(); diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index fb0e4bd..e330f90 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustSyncApi String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 264517153; + int get rustContentHash => 1656309947; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -97,6 +97,8 @@ abstract class RustSyncApiApi extends BaseApi { Future crateApiFfiGetSyncConfig(); + Future crateApiFfiGetSyncCumStats(); + Future crateApiFfiGetSyncStatus(); Future> crateApiFfiGetTaskDetail({ @@ -115,7 +117,7 @@ abstract class RustSyncApiApi extends BaseApi { Stream crateApiFfiRegisterSyncEventSink(); - Future crateApiFfiResetSync(); + Future crateApiFfiResetSync({required bool deleteLocalFiles}); Future crateApiFfiResumeSync(); @@ -376,7 +378,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform const TaskConstMeta(debugName: "get_sync_config", argNames: []); @override - Future crateApiFfiGetSyncStatus() { + Future crateApiFfiGetSyncCumStats() { return handler.executeNormal( NormalTask( callFfi: (port_) { @@ -388,6 +390,33 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform port: port_, ); }, + codec: SseCodec( + decodeSuccessData: sse_decode_sync_cum_stats_ffi, + decodeErrorData: sse_decode_sync_error_ffi, + ), + constMeta: kCrateApiFfiGetSyncCumStatsConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiFfiGetSyncCumStatsConstMeta => + const TaskConstMeta(debugName: "get_sync_cum_stats", argNames: []); + + @override + Future crateApiFfiGetSyncStatus() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 10, + port: port_, + ); + }, codec: SseCodec( decodeSuccessData: sse_decode_sync_status_ffi, decodeErrorData: sse_decode_sync_error_ffi, @@ -414,7 +443,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 10, + funcId: 11, port: port_, ); }, @@ -442,7 +471,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 11, + funcId: 12, port: port_, ); }, @@ -470,7 +499,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 12, + funcId: 13, port: port_, ); }, @@ -497,7 +526,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 13, + funcId: 14, port: port_, ); }, @@ -527,7 +556,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 14, + funcId: 15, port: port_, ); }, @@ -557,7 +586,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 15, + funcId: 16, port: port_, ); }, @@ -581,15 +610,16 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform ); @override - Future crateApiFfiResetSync() { + Future crateApiFfiResetSync({required bool deleteLocalFiles}) { return handler.executeNormal( NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_bool(deleteLocalFiles, serializer); pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 16, + funcId: 17, port: port_, ); }, @@ -598,14 +628,16 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform decodeErrorData: sse_decode_sync_error_ffi, ), constMeta: kCrateApiFfiResetSyncConstMeta, - argValues: [], + argValues: [deleteLocalFiles], apiImpl: this, ), ); } - TaskConstMeta get kCrateApiFfiResetSyncConstMeta => - const TaskConstMeta(debugName: "reset_sync", argNames: []); + TaskConstMeta get kCrateApiFfiResetSyncConstMeta => const TaskConstMeta( + debugName: "reset_sync", + argNames: ["deleteLocalFiles"], + ); @override Future crateApiFfiResumeSync() { @@ -616,7 +648,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 17, + funcId: 18, port: port_, ); }, @@ -644,7 +676,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 18, + funcId: 19, port: port_, ); }, @@ -671,7 +703,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 19, + funcId: 20, port: port_, ); }, @@ -698,7 +730,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 20, + funcId: 21, port: port_, ); }, @@ -725,7 +757,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 21, + funcId: 22, port: port_, ); }, @@ -757,7 +789,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 22, + funcId: 23, port: port_, ); }, @@ -787,7 +819,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 23, + funcId: 24, port: port_, ); }, @@ -815,7 +847,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 24, + funcId: 25, port: port_, ); }, @@ -846,7 +878,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 25, + funcId: 26, port: port_, ); }, @@ -916,13 +948,15 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform ) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 4) - throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + if (arr.length != 6) + throw Exception('unexpected arr length: expect 6 but see ${arr.length}'); return CloudAlbumCheckResultFfi( dcimExists: dco_decode_bool(arr[0]), picturesExists: dco_decode_bool(arr[1]), dcimUri: dco_decode_opt_String(arr[2]), picturesUri: dco_decode_opt_String(arr[3]), + cameraExists: dco_decode_bool(arr[4]), + cameraUri: dco_decode_opt_String(arr[5]), ); } @@ -987,6 +1021,25 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform ); } + @protected + SyncCumStatsFfi dco_decode_sync_cum_stats_ffi(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 9) + throw Exception('unexpected arr length: expect 9 but see ${arr.length}'); + return SyncCumStatsFfi( + uploaded: dco_decode_u_32(arr[0]), + downloaded: dco_decode_u_32(arr[1]), + renamed: dco_decode_u_32(arr[2]), + moved: dco_decode_u_32(arr[3]), + failed: dco_decode_u_32(arr[4]), + conflicts: dco_decode_u_32(arr[5]), + deletedLocal: dco_decode_u_32(arr[6]), + deletedRemote: dco_decode_u_32(arr[7]), + skipped: dco_decode_u_32(arr[8]), + ); + } + @protected SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1265,11 +1318,15 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform var var_picturesExists = sse_decode_bool(deserializer); var var_dcimUri = sse_decode_opt_String(deserializer); var var_picturesUri = sse_decode_opt_String(deserializer); + var var_cameraExists = sse_decode_bool(deserializer); + var var_cameraUri = sse_decode_opt_String(deserializer); return CloudAlbumCheckResultFfi( dcimExists: var_dcimExists, picturesExists: var_picturesExists, dcimUri: var_dcimUri, picturesUri: var_picturesUri, + cameraExists: var_cameraExists, + cameraUri: var_cameraUri, ); } @@ -1374,6 +1431,31 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform ); } + @protected + SyncCumStatsFfi sse_decode_sync_cum_stats_ffi(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_uploaded = sse_decode_u_32(deserializer); + var var_downloaded = sse_decode_u_32(deserializer); + var var_renamed = sse_decode_u_32(deserializer); + var var_moved = sse_decode_u_32(deserializer); + var var_failed = sse_decode_u_32(deserializer); + var var_conflicts = sse_decode_u_32(deserializer); + var var_deletedLocal = sse_decode_u_32(deserializer); + var var_deletedRemote = sse_decode_u_32(deserializer); + var var_skipped = sse_decode_u_32(deserializer); + return SyncCumStatsFfi( + uploaded: var_uploaded, + downloaded: var_downloaded, + renamed: var_renamed, + moved: var_moved, + failed: var_failed, + conflicts: var_conflicts, + deletedLocal: var_deletedLocal, + deletedRemote: var_deletedRemote, + skipped: var_skipped, + ); + } + @protected SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1738,6 +1820,8 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform sse_encode_bool(self.picturesExists, serializer); sse_encode_opt_String(self.dcimUri, serializer); sse_encode_opt_String(self.picturesUri, serializer); + sse_encode_bool(self.cameraExists, serializer); + sse_encode_opt_String(self.cameraUri, serializer); } @protected @@ -1822,6 +1906,23 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform sse_encode_String(self.logLevel, serializer); } + @protected + void sse_encode_sync_cum_stats_ffi( + SyncCumStatsFfi self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_u_32(self.uploaded, serializer); + sse_encode_u_32(self.downloaded, serializer); + sse_encode_u_32(self.renamed, serializer); + sse_encode_u_32(self.moved, serializer); + sse_encode_u_32(self.failed, serializer); + sse_encode_u_32(self.conflicts, serializer); + sse_encode_u_32(self.deletedLocal, serializer); + sse_encode_u_32(self.deletedRemote, serializer); + sse_encode_u_32(self.skipped, serializer); + } + @protected void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs diff --git a/lib/src/rust/frb_generated.io.dart b/lib/src/rust/frb_generated.io.dart index c02cdfc..283152f 100644 --- a/lib/src/rust/frb_generated.io.dart +++ b/lib/src/rust/frb_generated.io.dart @@ -66,6 +66,9 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl { @protected SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw); + @protected + SyncCumStatsFfi dco_decode_sync_cum_stats_ffi(dynamic raw); + @protected SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw); @@ -156,6 +159,9 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl { @protected SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer); + @protected + SyncCumStatsFfi sse_decode_sync_cum_stats_ffi(SseDeserializer deserializer); + @protected SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer); @@ -266,6 +272,12 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl { @protected void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer); + @protected + void sse_encode_sync_cum_stats_ffi( + SyncCumStatsFfi self, + SseSerializer serializer, + ); + @protected void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer); diff --git a/lib/src/rust/frb_generated.web.dart b/lib/src/rust/frb_generated.web.dart index 7188d14..71c4990 100644 --- a/lib/src/rust/frb_generated.web.dart +++ b/lib/src/rust/frb_generated.web.dart @@ -68,6 +68,9 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl { @protected SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw); + @protected + SyncCumStatsFfi dco_decode_sync_cum_stats_ffi(dynamic raw); + @protected SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw); @@ -158,6 +161,9 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl { @protected SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer); + @protected + SyncCumStatsFfi sse_decode_sync_cum_stats_ffi(SseDeserializer deserializer); + @protected SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer); @@ -268,6 +274,12 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl { @protected void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer); + @protected + void sse_encode_sync_cum_stats_ffi( + SyncCumStatsFfi self, + SseSerializer serializer, + ); + @protected void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer); diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 3076e8a..0cf4f31 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -149,6 +149,7 @@ else() COMMAND ${CARGO_CMD} build ${CARGO_PROFILE} --manifest-path "${SYNC_CORE_DIR}/Cargo.toml" --target-dir "${RUST_TARGET_DIR}" + --features sync-core/linux-fuse,sync-core/event_sink_enabled COMMENT "Building Rust sync engine..." ) diff --git a/native/Cargo.lock b/native/Cargo.lock index 1fa9f1f..d520d2b 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -379,6 +379,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -482,6 +492,22 @@ dependencies = [ "libc", ] +[[package]] +name = "fuser" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53274f494609e77794b627b1a3cddfe45d675a6b2e9ba9c0fdc8d8eee2184369" +dependencies = [ + "libc", + "log", + "memchr", + "nix", + "page_size", + "pkg-config", + "smallvec", + "zerocopy", +] + [[package]] name = "futures" version = "0.3.32" @@ -1194,6 +1220,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "notify" version = "7.0.0" @@ -1316,6 +1354,16 @@ dependencies = [ "log", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1821,6 +1869,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1870,9 +1928,11 @@ dependencies = [ name = "sync-android" version = "0.1.0" dependencies = [ + "android_logger", "anyhow", "async-trait", "jni", + "log", "thiserror 2.0.18", "tokio", "tracing", @@ -1882,6 +1942,7 @@ dependencies = [ name = "sync-core" version = "0.1.0" dependencies = [ + "android_logger", "anyhow", "async-trait", "bytes", @@ -1889,10 +1950,13 @@ dependencies = [ "dashmap 6.1.0", "filetime", "flutter_rust_bridge", + "fuser", "futures", "futures-util", "hex", "jni", + "libc", + "log", "mime_guess", "notify-debouncer-full", "once_cell", @@ -2060,6 +2124,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/native/frb.yaml b/native/frb.yaml index 2b3c27d..41f3a6e 100644 --- a/native/frb.yaml +++ b/native/frb.yaml @@ -1,5 +1,5 @@ rust_input: crate::api rust_root: sync-core/ -dart_output: D:/flutter_projects/cloudreve4_flutter/lib/src/rust/ +dart_output: ../lib/src/rust/ dart_entrypoint_class_name: RustSyncApi dart_enums_style: true diff --git a/native/sync-android/Cargo.toml b/native/sync-android/Cargo.toml index 916a5cb..d4e4224 100644 --- a/native/sync-android/Cargo.toml +++ b/native/sync-android/Cargo.toml @@ -14,3 +14,10 @@ tracing = "0.1" anyhow = { workspace = true } tokio = { workspace = true } async-trait = "0.1" + +# 通用日志接口 +log = "0.4" + +# 仅在 Android 平台下生效的日志后端 +[target.'cfg(target_os = "android")'.dependencies] +android_logger = "0.15" diff --git a/native/sync-core/Cargo.toml b/native/sync-core/Cargo.toml index 96ca817..604e647 100644 --- a/native/sync-core/Cargo.toml +++ b/native/sync-core/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["cdylib", "lib"] flutter_rust_bridge = "=2.12.0" # 异步运行时 -tokio = { workspace = true } +tokio = { workspace = true, features = ["signal"] } # HTTP reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false } @@ -67,15 +67,20 @@ windows = { version = "0.58", optional = true } [target.'cfg(target_os = "linux")'.dependencies] sync-linux = { path = "../sync-linux", optional = true } +fuser = { version = "0.15", optional = true } +libc = { version = "0.2", optional = true } [target.'cfg(target_os = "android")'.dependencies] sync-android = { path = "../sync-android", optional = true } jni = { version = "0.21", optional = true } +android_logger = "0.15" +log = "0.4" [features] default = [] windows-cfapi = ["sync-windows", "windows"] linux-notify = ["sync-linux"] +linux-fuse = ["fuser", "libc"] android-media = ["sync-android", "jni"] event_sink_enabled = [] diff --git a/native/sync-core/src/api/ffi.rs b/native/sync-core/src/api/ffi.rs index 3e16de2..613cfbd 100644 --- a/native/sync-core/src/api/ffi.rs +++ b/native/sync-core/src/api/ffi.rs @@ -4,6 +4,75 @@ use std::sync::Arc; use crate::api::ffi_types::*; use crate::sync_engine::SyncEngine; +#[cfg(target_os = "android")] +mod android_log { + use std::fmt::Write; + use tracing::{Event, Level, Subscriber}; + use tracing_subscriber::layer::{Context, Layer}; + use tracing_subscriber::registry::LookupSpan; + + /// Tracing Layer:将事件转发到 `log` crate → android_logger → Logcat + pub struct AndroidLogLayer; + + impl Layer for AndroidLogLayer + where + S: Subscriber + for<'a> LookupSpan<'a>, + { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let log_level = match *event.metadata().level() { + Level::ERROR => log::Level::Error, + Level::WARN => log::Level::Warn, + Level::INFO => log::Level::Info, + Level::DEBUG => log::Level::Debug, + Level::TRACE => log::Level::Trace, + }; + + let mut visitor = EventVisitor::default(); + event.record(&mut visitor); + + let target = event.metadata().target(); + if visitor.message.is_empty() { + log::log!(log_level, "[{}] {}", target, visitor.fields.trim_end()); + } else if visitor.fields.is_empty() { + log::log!(log_level, "[{}] {}", target, visitor.message); + } else { + log::log!( + log_level, + "[{}] {} {}", + target, + visitor.message, + visitor.fields.trim_end() + ); + } + } + } + + #[derive(Default)] + struct EventVisitor { + message: String, + fields: String, + } + + impl tracing::field::Visit for EventVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + // message 字段由 tracing::field::display() 包装,其 Debug 实际走 Display,无多余引号 + write!(self.message, "{:?}", value).unwrap(); + } else { + write!(self.fields, "{}={:?} ", field.name(), value).unwrap(); + } + } + } + + pub fn init_android_logger() { + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Trace) + .with_tag("RustSyncCore"), + ); + } +} + /// 全局引擎实例 static ENGINE: once_cell::sync::OnceCell> = once_cell::sync::OnceCell::new(); @@ -36,7 +105,8 @@ fn config_from_ffi(ffi: SyncConfigFfi) -> crate::models::SyncConfig { let sync_mode = match ffi.sync_mode.as_str() { "upload_only" => SyncMode::UploadOnly, "download_only" => SyncMode::DownloadOnly, - "album" => SyncMode::Album, + "album_upload" => SyncMode::AlbumUpload, + "album_download" => SyncMode::AlbumDownload, "mirror_wcf" => SyncMode::MirrorWcf, _ => SyncMode::Full, }; @@ -86,7 +156,8 @@ fn config_to_ffi(c: &crate::models::SyncConfig) -> SyncConfigFfi { SyncMode::Full => "full", SyncMode::UploadOnly => "upload_only", SyncMode::DownloadOnly => "download_only", - SyncMode::Album => "album", + SyncMode::AlbumUpload => "album_upload", + SyncMode::AlbumDownload => "album_download", SyncMode::MirrorWcf => "mirror_wcf", }; @@ -174,12 +245,17 @@ fn album_result_to_ffi(r: crate::models::CloudAlbumCheckResult) -> CloudAlbumChe pictures_exists: r.pictures_exists, dcim_uri: r.dcim_uri, pictures_uri: r.pictures_uri, + camera_exists: r.camera_exists, + camera_uri: r.camera_uri, } } /// 获取引擎引用,未初始化则返回错误 fn get_engine() -> Result<&'static SyncEngine, SyncErrorFfi> { - ENGINE.get().map(|arc| arc.as_ref()).ok_or(SyncErrorFfi::NotInitialized) + ENGINE + .get() + .map(|arc| arc.as_ref()) + .ok_or(SyncErrorFfi::NotInitialized) } /// 内部:应用日志级别到 reload handle @@ -244,6 +320,10 @@ pub async fn init_sync_engine(config: SyncConfigFfi) -> Result<(), SyncErrorFfi> eprintln!("[sync-core] 警告: 无法创建日志文件 {}", log_path.display()); } + // Android: 初始化 Logcat 日志后端(tracing → log → android_logger → Logcat) + #[cfg(target_os = "android")] + android_log::init_android_logger(); + // 尝试初始化 subscriber(仅首次有效,后续调用忽略) { use tracing_subscriber::layer::SubscriberExt; @@ -258,18 +338,22 @@ pub async fn init_sync_engine(config: SyncConfigFfi) -> Result<(), SyncErrorFfi> let registry = tracing_subscriber::registry().with(reload_filter); + // Android: 添加 Logcat 桥接层 + #[cfg(target_os = "android")] + let registry = registry.with(android_log::AndroidLogLayer); + if let Some(file) = log_file { let _ = registry - .with(tracing_subscriber::fmt::layer() - .with_writer(std::sync::Mutex::new(file)) - .with_ansi(false)) - .with(tracing_subscriber::fmt::layer() - .with_writer(std::io::stderr)) + .with( + tracing_subscriber::fmt::layer() + .with_writer(std::sync::Mutex::new(file)) + .with_ansi(false), + ) + .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)) .try_init(); } else { let _ = registry - .with(tracing_subscriber::fmt::layer() - .with_writer(std::io::stderr)) + .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)) .try_init(); } } @@ -281,10 +365,12 @@ pub async fn init_sync_engine(config: SyncConfigFfi) -> Result<(), SyncErrorFfi> let log_bandwidth = config.bandwidth_limit_kbps; let log_level = config.log_level.clone(); - let engine = SyncEngine::new(config_from_ffi(config)).await + let engine = SyncEngine::new(config_from_ffi(config)) + .await .map_err(error_to_ffi)?; - ENGINE.set(Arc::new(engine)) + ENGINE + .set(Arc::new(engine)) .map_err(|_| SyncErrorFfi::InternalError { message: "引擎已初始化".to_string(), })?; @@ -292,7 +378,10 @@ pub async fn init_sync_engine(config: SyncConfigFfi) -> Result<(), SyncErrorFfi> tracing::info!("同步引擎初始化完成, 日志文件: {}", log_path.display()); tracing::info!( "配置: 模式={}, 冲突策略={}, 并发={}, 带宽限制={}kbps", - log_sync_mode, log_conflict_strategy, log_max_concurrent, log_bandwidth, + log_sync_mode, + log_conflict_strategy, + log_max_concurrent, + log_bandwidth, ); if log_bandwidth > 0 { tracing::info!("仅对下载限速生效, 由于Cloudreve实现原因, 上传限速无法生效"); @@ -301,6 +390,41 @@ pub async fn init_sync_engine(config: SyncConfigFfi) -> Result<(), SyncErrorFfi> // 应用配置中的日志级别(热修改覆盖默认 debug) apply_log_level(&log_level); + // 注册 SIGINT/SIGTERM 信号处理,确保 FUSE/WCF 等资源被优雅清理 + #[cfg(unix)] + { + tokio::spawn(async { + let mut sigterm = + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(s) => s, + Err(e) => { + tracing::warn!("无法注册 SIGTERM 处理: {}", e); + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => { + tracing::info!("收到 SIGINT,开始清理..."); + } + _ = sigterm.recv() => { + tracing::info!("收到 SIGTERM,开始清理..."); + } + } + sync_shutdown().ok(); + std::process::exit(0); + }); + } + #[cfg(not(unix))] + { + tokio::spawn(async { + if tokio::signal::ctrl_c().await.is_ok() { + tracing::info!("收到 Ctrl+C,开始清理..."); + sync_shutdown().ok(); + std::process::exit(0); + } + }); + } + Ok(()) } @@ -316,11 +440,16 @@ pub async fn dispose_sync_engine() -> Result<(), SyncErrorFfi> { engine.cleanup_wcf(); } + #[cfg(feature = "linux-fuse")] + { + engine.cleanup_fuse(); + } + tracing::info!("同步引擎已停止"); Ok(()) } -/// 进程退出前同步清理(WCF 模式下必须调用,确保占位符释放) +/// 进程退出前同步清理(WCF/FUSE 模式下必须调用,确保占位符释放和挂载点卸载) /// 此函数是同步的,不依赖 tokio runtime,可安全在 exit(0) 前调用 #[frb] pub fn sync_shutdown() -> Result<(), SyncErrorFfi> { @@ -333,6 +462,14 @@ pub fn sync_shutdown() -> Result<(), SyncErrorFfi> { }; engine.cleanup_wcf(); } + #[cfg(feature = "linux-fuse")] + { + let engine = match ENGINE.get() { + Some(e) => e, + None => return Ok(()), + }; + engine.cleanup_fuse(); + } tracing::info!("同步引擎已同步清理"); Ok(()) } @@ -345,10 +482,17 @@ pub async fn start_initial_sync() -> Result { tracing::debug!("[FFI] start_initial_sync ←"); let engine = get_engine()?; engine.ensure_token_fresh(); - engine.run_initial_sync().await + engine + .run_initial_sync() + .await .map(|s| { - tracing::debug!("[FFI] start_initial_sync → uploaded={}, downloaded={}, conflicts={}, failed={}", - s.uploaded, s.downloaded, s.conflicts, s.failed); + tracing::debug!( + "[FFI] start_initial_sync → uploaded={}, downloaded={}, conflicts={}, failed={}", + s.uploaded, + s.downloaded, + s.conflicts, + s.failed + ); summary_to_ffi(s) }) .map_err(error_to_ffi) @@ -398,21 +542,34 @@ pub async fn resume_sync() -> Result<(), SyncErrorFfi> { pub async fn force_sync() -> Result { tracing::debug!("[FFI] force_sync ←"); let engine = get_engine()?; - engine.force_sync().await + engine + .force_sync() + .await .map(|s| { - tracing::debug!("[FFI] force_sync → uploaded={}, downloaded={}, conflicts={}, failed={}", - s.uploaded, s.downloaded, s.conflicts, s.failed); + tracing::debug!( + "[FFI] force_sync → uploaded={}, downloaded={}, conflicts={}, failed={}", + s.uploaded, + s.downloaded, + s.conflicts, + s.failed + ); summary_to_ffi(s) }) .map_err(error_to_ffi) } -/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 +/// 重置同步:停止任务 → 清空 DB → (可选)清空本地目录 → 回到初始状态 #[frb] -pub async fn reset_sync() -> Result<(), SyncErrorFfi> { - tracing::debug!("[FFI] reset_sync ←"); +pub async fn reset_sync(delete_local_files: bool) -> Result<(), SyncErrorFfi> { + tracing::debug!( + "[FFI] reset_sync ← delete_local_files={}", + delete_local_files + ); let engine = get_engine()?; - engine.reset_sync().await.map_err(error_to_ffi) + engine + .reset_sync(delete_local_files) + .await + .map_err(error_to_ffi) } // ========== 状态查询 ========== @@ -421,8 +578,13 @@ pub async fn reset_sync() -> Result<(), SyncErrorFfi> { #[frb] pub async fn get_sync_status() -> Result { let engine = get_engine()?; - let s = engine.status(); - tracing::trace!("[FFI] get_sync_status → state={:?}, synced={}, total={}", s.state, s.synced_files, s.total_files); + let s = engine.status().await; + tracing::trace!( + "[FFI] get_sync_status → state={:?}, synced={}, total={}", + s.state, + s.synced_files, + s.total_files + ); Ok(status_to_ffi(s)) } @@ -440,7 +602,11 @@ pub async fn get_active_worker_count() -> Result { pub async fn get_sync_config() -> Result { let engine = get_engine()?; let c = engine.config().await; - tracing::trace!("[FFI] get_sync_config → mode={:?}, conflict={:?}", c.sync_mode, c.conflict_strategy); + tracing::trace!( + "[FFI] get_sync_config → mode={:?}, conflict={:?}", + c.sync_mode, + c.conflict_strategy + ); Ok(config_to_ffi(&c)) } @@ -450,7 +616,10 @@ pub async fn update_sync_config(config: SyncConfigFfi) -> Result<(), SyncErrorFf tracing::debug!("[FFI] update_sync_config ← mode={}, conflict={}, wcf_delete={}, concurrent={}, bandwidth={}kbps", config.sync_mode, config.conflict_strategy, config.wcf_delete_mode, config.max_concurrent_transfers, config.bandwidth_limit_kbps); let engine = get_engine()?; - engine.update_config(config_from_ffi(config)).await.map_err(error_to_ffi) + engine + .update_config(config_from_ffi(config)) + .await + .map_err(error_to_ffi) } // ========== Token 管理 ========== @@ -482,19 +651,34 @@ pub async fn sync_album_to_cloud( album_paths: Vec, remote_dcim_uri: String, ) -> Result<(), SyncErrorFfi> { - tracing::debug!("[FFI] sync_album_to_cloud ← paths={}, uri={}", album_paths.len(), remote_dcim_uri); + tracing::debug!( + "[FFI] sync_album_to_cloud ← paths={}, uri={}", + album_paths.len(), + remote_dcim_uri + ); let engine = get_engine()?; - engine.sync_album(album_paths, &remote_dcim_uri).await.map_err(error_to_ffi) + engine + .sync_album(album_paths, &remote_dcim_uri) + .await + .map_err(error_to_ffi) } /// 检查云端是否存在 DCIM/Pictures 目录 #[frb] -pub async fn check_cloud_album_dirs(base_uri: String) -> Result { +pub async fn check_cloud_album_dirs( + base_uri: String, +) -> Result { tracing::debug!("[FFI] check_cloud_album_dirs ← uri={}", base_uri); let engine = get_engine()?; - engine.check_album_dirs(&base_uri).await + engine + .check_album_dirs(&base_uri) + .await .map(|r| { - tracing::debug!("[FFI] check_cloud_album_dirs → dcim={}, pictures={}", r.dcim_exists, r.pictures_exists); + tracing::debug!( + "[FFI] check_cloud_album_dirs → dcim={}, pictures={}", + r.dcim_exists, + r.pictures_exists + ); album_result_to_ffi(r) }) .map_err(error_to_ffi) @@ -505,18 +689,26 @@ pub async fn check_cloud_album_dirs(base_uri: String) -> Result Result<(), SyncErrorFfi> { tracing::debug!("[FFI] create_cloud_album_dirs ← uri={}", base_uri); let engine = get_engine()?; - engine.create_album_dirs(&base_uri).await.map_err(error_to_ffi) + engine + .create_album_dirs(&base_uri) + .await + .map_err(error_to_ffi) } // ========== 事件推送 ========== /// 注册 Rust→Dart 事件推送通道 #[frb] -pub fn register_sync_event_sink(sink: crate::frb_generated::StreamSink) -> Result<(), SyncErrorFfi> { +pub fn register_sync_event_sink( + sink: crate::frb_generated::StreamSink, +) -> Result<(), SyncErrorFfi> { tracing::debug!("[FFI] register_sync_event_sink ←"); let engine = get_engine()?; - // 使用 tokio runtime 注册 - let rt = tokio::runtime::Handle::current(); + // flutter_rust_bridge 可能在非 Tokio 线程调用此同步函数, + // 使用 spawn_blocking + block_on 确保 runtime 上下文可用 + let rt = tokio::runtime::Runtime::new().map_err(|e| SyncErrorFfi::InternalError { + message: format!("创建 Tokio runtime 失败: {}", e), + })?; rt.block_on(engine.register_event_sink(sink)); Ok(()) } @@ -569,16 +761,26 @@ pub async fn get_recent_tasks(limit: u32) -> Result, SyncErrorF pub async fn get_task_detail(task_id: String) -> Result, SyncErrorFfi> { tracing::trace!("[FFI] get_task_detail ← task_id={}", task_id); let engine = get_engine()?; - let items = engine.get_task_detail(&task_id).await.map_err(error_to_ffi)?; + let items = engine + .get_task_detail(&task_id) + .await + .map_err(error_to_ffi)?; tracing::trace!("[FFI] get_task_detail → count={}", items.len()); Ok(items.into_iter().map(task_item_to_ffi).collect()) } /// 多维度查询任务项 #[frb] -pub async fn query_task_items(filter: TaskItemFilterFfi) -> Result, SyncErrorFfi> { - tracing::trace!("[FFI] query_task_items ← task_id={:?}, action={:?}, status={:?}, limit={}", - filter.task_id, filter.action_type, filter.status, filter.limit); +pub async fn query_task_items( + filter: TaskItemFilterFfi, +) -> Result, SyncErrorFfi> { + tracing::trace!( + "[FFI] query_task_items ← task_id={:?}, action={:?}, status={:?}, limit={}", + filter.task_id, + filter.action_type, + filter.status, + filter.limit + ); let engine = get_engine()?; let model_filter = crate::models::TaskItemFilter { task_id: filter.task_id, @@ -588,11 +790,35 @@ pub async fn query_task_items(filter: TaskItemFilterFfi) -> Result Result { + tracing::trace!("[FFI] get_sync_cum_stats ←"); + let engine = get_engine()?; + let stats = engine.get_cum_stats().await.map_err(error_to_ffi)?; + tracing::trace!("[FFI] get_sync_cum_stats → uploaded={}, downloaded={}, renamed={}, moved={}, failed={}, conflicts={}, deleted_local={}, deleted_remote={}, skipped={}", + stats.uploaded, stats.downloaded, stats.renamed, stats.moved, stats.failed, stats.conflicts, stats.deleted_local, stats.deleted_remote, stats.skipped); + Ok(SyncCumStatsFfi { + uploaded: stats.uploaded, + downloaded: stats.downloaded, + renamed: stats.renamed, + moved: stats.moved, + failed: stats.failed, + conflicts: stats.conflicts, + deleted_local: stats.deleted_local, + deleted_remote: stats.deleted_remote, + skipped: stats.skipped, + }) +} + fn task_to_ffi(t: crate::models::SyncTask) -> SyncTaskFfi { SyncTaskFfi { id: t.id, diff --git a/native/sync-core/src/api/ffi_types.rs b/native/sync-core/src/api/ffi_types.rs index 36bdf44..c7ff581 100644 --- a/native/sync-core/src/api/ffi_types.rs +++ b/native/sync-core/src/api/ffi_types.rs @@ -61,7 +61,9 @@ pub struct SyncSummaryFfi { /// 同步事件(Rust → Dart 推送) #[derive(Debug, Clone)] pub enum SyncEventFfi { - StateChanged { new_state: String }, + StateChanged { + new_state: String, + }, Progress { synced: u64, total: u64, @@ -84,8 +86,12 @@ pub enum SyncEventFfi { recoverable: bool, }, TokenExpired, - DiskSpaceWarning { available_mb: u64 }, - InitialSyncComplete { summary: SyncSummaryFfi }, + DiskSpaceWarning { + available_mb: u64, + }, + InitialSyncComplete { + summary: SyncSummaryFfi, + }, // Worker 事件 WorkerStarted { @@ -122,6 +128,8 @@ pub struct CloudAlbumCheckResultFfi { pub pictures_exists: bool, pub dcim_uri: Option, pub pictures_uri: Option, + pub camera_exists: bool, + pub camera_uri: Option, } /// 同步任务摘要(FFI) @@ -152,6 +160,20 @@ pub struct SyncTaskItemFfi { pub updated_at: String, } +/// 累积统计(FFI) +#[derive(Debug, Clone)] +pub struct SyncCumStatsFfi { + pub uploaded: u32, + pub downloaded: u32, + pub renamed: u32, + pub moved: u32, + pub failed: u32, + pub conflicts: u32, + pub deleted_local: u32, + pub deleted_remote: u32, + pub skipped: u32, +} + /// 任务项查询过滤器(FFI) #[derive(Debug, Clone)] pub struct TaskItemFilterFfi { diff --git a/native/sync-core/src/diff.rs b/native/sync-core/src/diff.rs index 0aa4a9b..2deef95 100644 --- a/native/sync-core/src/diff.rs +++ b/native/sync-core/src/diff.rs @@ -14,7 +14,12 @@ pub fn compute_diff( // 构建索引: relative_path → entry(统一正斜杠) let local_map: HashMap = local_files .iter() - .map(|e| (crate::utils::normalize_path(&e.relative_path.to_string_lossy()), e)) + .map(|e| { + ( + crate::utils::normalize_path(&e.relative_path.to_string_lossy()), + e, + ) + }) .collect(); let remote_map: HashMap = remote_files @@ -43,9 +48,9 @@ pub fn compute_diff( let db = db_mappings.get(path.as_str()); match (local, remote, db) { - // 本地有,远程无 → 上传(UploadOnly、Full 和 MirrorWcf) + // 本地有,远程无 → 上传(UploadOnly、Full、MirrorWcf、AlbumUpload) (Some(l), None, _) => { - if matches!(sync_mode, SyncMode::DownloadOnly) { + if matches!(sync_mode, SyncMode::DownloadOnly | SyncMode::AlbumDownload) { continue; } if !l.is_dir && l.size == 0 { @@ -70,9 +75,9 @@ pub fn compute_diff( } } - // 远程有,本地无 → 下载(DownloadOnly、Full 和 MirrorWcf) + // 远程有,本地无 → 下载(DownloadOnly、Full、MirrorWcf、AlbumDownload) (None, Some(r), _) => { - if matches!(sync_mode, SyncMode::UploadOnly) { + if matches!(sync_mode, SyncMode::UploadOnly | SyncMode::AlbumUpload) { continue; } if r.is_dir { @@ -125,8 +130,8 @@ pub fn compute_diff( } } - // 远程目录结构(UploadOnly、Full 和 MirrorWcf) - if !matches!(sync_mode, SyncMode::DownloadOnly) { + // 远程目录结构(UploadOnly、Full、MirrorWcf、AlbumUpload) + if !matches!(sync_mode, SyncMode::DownloadOnly | SyncMode::AlbumDownload) { for (path, local) in &local_map { if local.is_dir && !remote_map.contains_key(path.as_str()) { plan.mkdirs_remote.push(path.clone()); @@ -149,7 +154,7 @@ fn resolve_conflicts_by_mode(plan: &mut SyncPlan, sync_mode: &SyncMode) { let conflicts = std::mem::take(&mut plan.conflicts); for conflict in conflicts { match sync_mode { - SyncMode::UploadOnly | SyncMode::MirrorWcf => { + SyncMode::UploadOnly | SyncMode::MirrorWcf | SyncMode::AlbumUpload => { // 冲突一律覆盖上传(MirrorWcf 本地编辑优先) let action = SyncAction { relative_path: conflict.relative_path.clone(), @@ -159,7 +164,7 @@ fn resolve_conflicts_by_mode(plan: &mut SyncPlan, sync_mode: &SyncMode) { }; plan.uploads.push(action); } - SyncMode::DownloadOnly => { + SyncMode::DownloadOnly | SyncMode::AlbumDownload => { // 冲突一律覆盖下载 let action = SyncAction { relative_path: conflict.relative_path.clone(), diff --git a/native/sync-core/src/frb_generated.rs b/native/sync-core/src/frb_generated.rs index d7128f5..275698d 100644 --- a/native/sync-core/src/frb_generated.rs +++ b/native/sync-core/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 264517153; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1656309947; // Section: executor @@ -331,6 +331,41 @@ fn wire__crate__api__ffi__get_sync_config_impl( }, ) } +fn wire__crate__api__ffi__get_sync_cum_stats_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "get_sync_cum_stats", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( + (move || async move { + let output_ok = crate::api::ffi::get_sync_cum_stats().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__ffi__get_sync_status_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -604,11 +639,12 @@ fn wire__crate__api__ffi__reset_sync_impl( }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_delete_local_files = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( (move || async move { - let output_ok = crate::api::ffi::reset_sync().await?; + let output_ok = crate::api::ffi::reset_sync(api_delete_local_files).await?; Ok(output_ok) })() .await, @@ -981,11 +1017,15 @@ impl SseDecode for crate::api::ffi_types::CloudAlbumCheckResultFfi { let mut var_picturesExists = ::sse_decode(deserializer); let mut var_dcimUri = >::sse_decode(deserializer); let mut var_picturesUri = >::sse_decode(deserializer); + let mut var_cameraExists = ::sse_decode(deserializer); + let mut var_cameraUri = >::sse_decode(deserializer); return crate::api::ffi_types::CloudAlbumCheckResultFfi { dcim_exists: var_dcimExists, pictures_exists: var_picturesExists, dcim_uri: var_dcimUri, pictures_uri: var_picturesUri, + camera_exists: var_cameraExists, + camera_uri: var_cameraUri, }; } } @@ -1098,6 +1138,32 @@ impl SseDecode for crate::api::ffi_types::SyncConfigFfi { } } +impl SseDecode for crate::api::ffi_types::SyncCumStatsFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_uploaded = ::sse_decode(deserializer); + let mut var_downloaded = ::sse_decode(deserializer); + let mut var_renamed = ::sse_decode(deserializer); + let mut var_moved = ::sse_decode(deserializer); + let mut var_failed = ::sse_decode(deserializer); + let mut var_conflicts = ::sse_decode(deserializer); + let mut var_deletedLocal = ::sse_decode(deserializer); + let mut var_deletedRemote = ::sse_decode(deserializer); + let mut var_skipped = ::sse_decode(deserializer); + return crate::api::ffi_types::SyncCumStatsFfi { + uploaded: var_uploaded, + downloaded: var_downloaded, + renamed: var_renamed, + moved: var_moved, + failed: var_failed, + conflicts: var_conflicts, + deleted_local: var_deletedLocal, + deleted_remote: var_deletedRemote, + skipped: var_skipped, + }; + } +} + impl SseDecode for crate::api::ffi_types::SyncErrorFfi { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1445,25 +1511,26 @@ fn pde_ffi_dispatcher_primary_impl( 6 => wire__crate__api__ffi__get_active_worker_count_impl(port, ptr, rust_vec_len, data_len), 7 => wire__crate__api__ffi__get_recent_tasks_impl(port, ptr, rust_vec_len, data_len), 8 => wire__crate__api__ffi__get_sync_config_impl(port, ptr, rust_vec_len, data_len), - 9 => wire__crate__api__ffi__get_sync_status_impl(port, ptr, rust_vec_len, data_len), - 10 => wire__crate__api__ffi__get_task_detail_impl(port, ptr, rust_vec_len, data_len), - 11 => wire__crate__api__ffi__hydrate_file_impl(port, ptr, rust_vec_len, data_len), - 12 => wire__crate__api__ffi__init_sync_engine_impl(port, ptr, rust_vec_len, data_len), - 13 => wire__crate__api__ffi__pause_sync_impl(port, ptr, rust_vec_len, data_len), - 14 => wire__crate__api__ffi__query_task_items_impl(port, ptr, rust_vec_len, data_len), - 15 => { + 9 => wire__crate__api__ffi__get_sync_cum_stats_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__api__ffi__get_sync_status_impl(port, ptr, rust_vec_len, data_len), + 11 => wire__crate__api__ffi__get_task_detail_impl(port, ptr, rust_vec_len, data_len), + 12 => wire__crate__api__ffi__hydrate_file_impl(port, ptr, rust_vec_len, data_len), + 13 => wire__crate__api__ffi__init_sync_engine_impl(port, ptr, rust_vec_len, data_len), + 14 => wire__crate__api__ffi__pause_sync_impl(port, ptr, rust_vec_len, data_len), + 15 => wire__crate__api__ffi__query_task_items_impl(port, ptr, rust_vec_len, data_len), + 16 => { wire__crate__api__ffi__register_sync_event_sink_impl(port, ptr, rust_vec_len, data_len) } - 16 => wire__crate__api__ffi__reset_sync_impl(port, ptr, rust_vec_len, data_len), - 17 => wire__crate__api__ffi__resume_sync_impl(port, ptr, rust_vec_len, data_len), - 18 => wire__crate__api__ffi__set_sync_log_level_impl(port, ptr, rust_vec_len, data_len), - 19 => wire__crate__api__ffi__start_continuous_sync_impl(port, ptr, rust_vec_len, data_len), - 20 => wire__crate__api__ffi__start_initial_sync_impl(port, ptr, rust_vec_len, data_len), - 21 => wire__crate__api__ffi__stop_sync_impl(port, ptr, rust_vec_len, data_len), - 22 => wire__crate__api__ffi__sync_album_to_cloud_impl(port, ptr, rust_vec_len, data_len), - 23 => wire__crate__api__ffi__sync_shutdown_impl(port, ptr, rust_vec_len, data_len), - 24 => wire__crate__api__ffi__update_sync_config_impl(port, ptr, rust_vec_len, data_len), - 25 => wire__crate__api__ffi__update_tokens_impl(port, ptr, rust_vec_len, data_len), + 17 => wire__crate__api__ffi__reset_sync_impl(port, ptr, rust_vec_len, data_len), + 18 => wire__crate__api__ffi__resume_sync_impl(port, ptr, rust_vec_len, data_len), + 19 => wire__crate__api__ffi__set_sync_log_level_impl(port, ptr, rust_vec_len, data_len), + 20 => wire__crate__api__ffi__start_continuous_sync_impl(port, ptr, rust_vec_len, data_len), + 21 => wire__crate__api__ffi__start_initial_sync_impl(port, ptr, rust_vec_len, data_len), + 22 => wire__crate__api__ffi__stop_sync_impl(port, ptr, rust_vec_len, data_len), + 23 => wire__crate__api__ffi__sync_album_to_cloud_impl(port, ptr, rust_vec_len, data_len), + 24 => wire__crate__api__ffi__sync_shutdown_impl(port, ptr, rust_vec_len, data_len), + 25 => wire__crate__api__ffi__update_sync_config_impl(port, ptr, rust_vec_len, data_len), + 26 => wire__crate__api__ffi__update_tokens_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -1490,6 +1557,8 @@ impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::CloudAlbumCheckRes self.pictures_exists.into_into_dart().into_dart(), self.dcim_uri.into_into_dart().into_dart(), self.pictures_uri.into_into_dart().into_dart(), + self.camera_exists.into_into_dart().into_dart(), + self.camera_uri.into_into_dart().into_dart(), ] .into_dart() } @@ -1540,6 +1609,34 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::SyncCumStatsFfi { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.uploaded.into_into_dart().into_dart(), + self.downloaded.into_into_dart().into_dart(), + self.renamed.into_into_dart().into_dart(), + self.moved.into_into_dart().into_dart(), + self.failed.into_into_dart().into_dart(), + self.conflicts.into_into_dart().into_dart(), + self.deleted_local.into_into_dart().into_dart(), + self.deleted_remote.into_into_dart().into_dart(), + self.skipped.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::ffi_types::SyncCumStatsFfi +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::ffi_types::SyncCumStatsFfi +{ + fn into_into_dart(self) -> crate::api::ffi_types::SyncCumStatsFfi { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::SyncErrorFfi { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self { @@ -1887,6 +1984,8 @@ impl SseEncode for crate::api::ffi_types::CloudAlbumCheckResultFfi { ::sse_encode(self.pictures_exists, serializer); >::sse_encode(self.dcim_uri, serializer); >::sse_encode(self.pictures_uri, serializer); + ::sse_encode(self.camera_exists, serializer); + >::sse_encode(self.camera_uri, serializer); } } @@ -1968,6 +2067,21 @@ impl SseEncode for crate::api::ffi_types::SyncConfigFfi { } } +impl SseEncode for crate::api::ffi_types::SyncCumStatsFfi { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.uploaded, serializer); + ::sse_encode(self.downloaded, serializer); + ::sse_encode(self.renamed, serializer); + ::sse_encode(self.moved, serializer); + ::sse_encode(self.failed, serializer); + ::sse_encode(self.conflicts, serializer); + ::sse_encode(self.deleted_local, serializer); + ::sse_encode(self.deleted_remote, serializer); + ::sse_encode(self.skipped, serializer); + } +} + impl SseEncode for crate::api::ffi_types::SyncErrorFfi { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/native/sync-core/src/fs_scanner.rs b/native/sync-core/src/fs_scanner.rs index 52cacab..11795bb 100644 --- a/native/sync-core/src/fs_scanner.rs +++ b/native/sync-core/src/fs_scanner.rs @@ -1,23 +1,16 @@ -#[cfg(unix)] -use std::collections::HashSet; use crate::errors::Result; use crate::models::LocalFileEntry; use crate::utils::quick_hash; +#[cfg(unix)] +use std::collections::HashSet; use std::path::Path; use walkdir::WalkDir; /// 需要跳过的文件/目录名前缀和名称 -pub const SKIP_NAMES: &[&str] = &[ - ".DS_Store", - "Thumbs.db", - "desktop.ini", -]; +pub const SKIP_NAMES: &[&str] = &[".DS_Store", "Thumbs.db", "desktop.ini"]; /// 需要跳过的文件扩展名(同步临时文件) -pub const SKIP_EXTENSIONS: &[&str] = &[ - "sync_tmp", - "sync_temp", -]; +pub const SKIP_EXTENSIONS: &[&str] = &["sync_tmp", "sync_temp"]; pub struct FsScanner; @@ -58,6 +51,15 @@ impl FsScanner { } }; + let file_name = entry.file_name().to_string_lossy(); + let depth = entry.depth(); + tracing::trace!( + "扫描: depth={}, is_dir={}, name={}", + depth, + entry.file_type().is_dir(), + file_name + ); + // 符号链接处理 if entry.path_is_symlink() && !follow_symlinks { continue; @@ -68,6 +70,10 @@ impl FsScanner { if SKIP_NAMES.iter().any(|s| file_name == *s) { continue; } + // 跳过隐藏目录/文件(以 . 开头) + if file_name.starts_with('.') { + continue; + } if file_name.starts_with(".sync_") { continue; } @@ -100,7 +106,9 @@ impl FsScanner { } } - let relative_path = entry.path().strip_prefix(root) + let relative_path = entry + .path() + .strip_prefix(root) .unwrap_or(entry.path()) .to_path_buf(); @@ -120,7 +128,8 @@ impl FsScanner { }); } else if metadata.is_file() { let size = metadata.len(); - let mtime_ms = metadata.modified() + let mtime_ms = metadata + .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_millis() as i64) @@ -144,13 +153,20 @@ impl FsScanner { } } + let dirs = entries.iter().filter(|e| e.is_dir).count(); + let files = entries.iter().filter(|e| !e.is_dir).count(); + tracing::debug!( + "扫描完成: {} 个条目 ({} 目录, {} 文件)", + entries.len(), + dirs, + files + ); + Ok(entries) } } /// 根据文件扩展名推断 MIME 类型 pub fn guess_mime_type(path: &Path) -> Option { - mime_guess::from_path(path) - .first() - .map(|m| m.to_string()) + mime_guess::from_path(path).first().map(|m| m.to_string()) } diff --git a/native/sync-core/src/models.rs b/native/sync-core/src/models.rs index 9aa50d2..b901a4c 100644 --- a/native/sync-core/src/models.rs +++ b/native/sync-core/src/models.rs @@ -49,7 +49,8 @@ pub enum SyncMode { Full, UploadOnly, DownloadOnly, - Album, + AlbumUpload, + AlbumDownload, MirrorWcf, } @@ -241,8 +242,14 @@ pub enum LocalFileEvent { Created(Vec), Modified(Vec), Deleted(Vec), - Renamed { old_paths: Vec, new_paths: Vec }, - Moved { old_paths: Vec, new_paths: Vec }, + Renamed { + old_paths: Vec, + new_paths: Vec, + }, + Moved { + old_paths: Vec, + new_paths: Vec, + }, } impl LocalFileEvent { @@ -262,9 +269,18 @@ impl LocalFileEvent { pub enum RemoteFileEvent { Created(RemoteFileEntry), Modified(RemoteFileEntry), - Deleted { uri: String, name: String }, - Renamed { old_uri: String, new_entry: RemoteFileEntry }, - Moved { old_uri: String, new_entry: RemoteFileEntry }, + Deleted { + uri: String, + name: String, + }, + Renamed { + old_uri: String, + new_entry: RemoteFileEntry, + }, + Moved { + old_uri: String, + new_entry: RemoteFileEntry, + }, } // ===== 平台回调事件 (Windows CFApi) ===== @@ -439,6 +455,10 @@ pub enum WorkerTrigger { InitialSync, Continuous, Manual, + /// WCF 按需水合 + Hydration, + /// WCF 远程事件(占位符/删除/重命名/移动) + WcfEvent, } impl WorkerTrigger { @@ -447,6 +467,8 @@ impl WorkerTrigger { WorkerTrigger::InitialSync => "initial_sync", WorkerTrigger::Continuous => "continuous", WorkerTrigger::Manual => "manual", + WorkerTrigger::Hydration => "hydration", + WorkerTrigger::WcfEvent => "wcf_event", } } } @@ -471,7 +493,6 @@ impl WorkerStatus { WorkerStatus::Cancelled => "cancelled", } } - } impl std::str::FromStr for WorkerStatus { @@ -509,7 +530,6 @@ impl TaskItemStatus { TaskItemStatus::Skipped => "skipped", } } - } impl std::str::FromStr for TaskItemStatus { @@ -540,6 +560,8 @@ pub enum TaskActionType { MkdirLocal, ConflictResolve, CreatePlaceholder, + /// WCF 按需水合(用户打开占位符文件时触发下载) + Hydration, } impl TaskActionType { @@ -555,9 +577,9 @@ impl TaskActionType { TaskActionType::MkdirLocal => "mkdir_local", TaskActionType::ConflictResolve => "conflict_resolve", TaskActionType::CreatePlaceholder => "create_placeholder", + TaskActionType::Hydration => "hydration", } } - } impl std::str::FromStr for TaskActionType { @@ -575,6 +597,7 @@ impl std::str::FromStr for TaskActionType { "mkdir_local" => Ok(TaskActionType::MkdirLocal), "conflict_resolve" => Ok(TaskActionType::ConflictResolve), "create_placeholder" => Ok(TaskActionType::CreatePlaceholder), + "hydration" => Ok(TaskActionType::Hydration), _ => Err(()), } } @@ -608,6 +631,20 @@ pub struct SyncTaskItem { pub updated_at: String, } +/// 累积统计(从 DB 聚合,跨所有同步任务) +#[derive(Debug, Clone, Default)] +pub struct SyncCumStats { + pub uploaded: u32, + pub downloaded: u32, + pub renamed: u32, + pub moved: u32, + pub failed: u32, + pub conflicts: u32, + pub deleted_local: u32, + pub deleted_remote: u32, + pub skipped: u32, +} + /// 任务项查询过滤器 #[derive(Debug, Clone, Default)] pub struct TaskItemFilter { @@ -627,4 +664,6 @@ pub struct CloudAlbumCheckResult { pub pictures_exists: bool, pub dcim_uri: Option, pub pictures_uri: Option, + pub camera_exists: bool, + pub camera_uri: Option, } diff --git a/native/sync-core/src/platform/fuse.rs b/native/sync-core/src/platform/fuse.rs new file mode 100644 index 0000000..9ba040d --- /dev/null +++ b/native/sync-core/src/platform/fuse.rs @@ -0,0 +1,1330 @@ +//! Linux FUSE 平台适配器 +//! 仅在 linux-fuse feature 启用时编译 +//! +//! 提供云端文件系统的 FUSE 读写挂载,支持: +//! - 按需水合(read 时下载) +//! - 写入上传(write → flush 时上传到云端) +//! - 创建/删除/重命名/移动 同步到远程 +//! - 远程事件 → inode 增量更新 + +#![cfg(feature = "linux-fuse")] + +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use dashmap::DashMap; +use fuser::{ + FileAttr, FileType, Filesystem, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry, ReplyStatfs, + ReplyWrite, Request, +}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use crate::api_client::ApiClient; +use crate::models::SyncConfig; +use crate::sync_db::SyncDb; + +// ========== 常量 ========== + +const FUSE_ROOT_INO: u64 = 1; +/// 小文件阈值:<= 此值纯内存缓冲,超过则落临时文件 +const MEMORY_BUFFER_THRESHOLD: u64 = 256 * 1024 * 1024; // 256MB + +// ========== Inode 存储 ========== + +#[derive(Debug, Clone)] +pub struct InodeEntry { + pub ino: u64, + pub parent_ino: u64, + pub name: String, + pub is_dir: bool, + pub size: u64, + pub mtime_ms: i64, + pub remote_uri: String, + pub remote_hash: Option, + pub dir_loaded: bool, +} + +pub struct InodeStore { + entries: DashMap, + path_to_inode: DashMap, + children: DashMap>, + next_inode: AtomicU64, +} + +impl Default for InodeStore { + fn default() -> Self { + Self::new() + } +} + +impl InodeStore { + pub fn new() -> Self { + let store = Self { + entries: DashMap::new(), + path_to_inode: DashMap::new(), + children: DashMap::new(), + next_inode: AtomicU64::new(FUSE_ROOT_INO + 1), + }; + store.entries.insert( + FUSE_ROOT_INO, + InodeEntry { + ino: FUSE_ROOT_INO, + parent_ino: FUSE_ROOT_INO, + name: String::new(), + is_dir: true, + size: 0, + mtime_ms: 0, + remote_uri: String::new(), + remote_hash: None, + dir_loaded: false, + }, + ); + store.path_to_inode.insert(String::new(), FUSE_ROOT_INO); + store + } + + pub fn alloc_inode(&self) -> u64 { + self.next_inode.fetch_add(1, Ordering::Relaxed) + } + + pub fn upsert( + &self, + relative_path: &str, + parent_ino: u64, + name: &str, + entry: InodeEntry, + ) -> u64 { + if let Some(existing_ino) = self.path_to_inode.get(relative_path).map(|r| *r.value()) { + if let Some(mut e) = self.entries.get_mut(&existing_ino) { + e.size = entry.size; + e.mtime_ms = entry.mtime_ms; + e.remote_uri = entry.remote_uri; + e.remote_hash = entry.remote_hash; + } + existing_ino + } else { + let ino = entry.ino; + self.entries.insert(ino, entry); + self.path_to_inode.insert(relative_path.to_string(), ino); + self.children + .entry(parent_ino) + .or_default() + .push((name.to_string(), ino)); + ino + } + } + + pub fn remove(&self, relative_path: &str) -> Option { + let (_, ino) = self.path_to_inode.remove(relative_path)?; + let (_, entry) = self.entries.remove(&ino)?; + if let Some(mut children) = self.children.get_mut(&entry.parent_ino) { + children.retain(|(child_name, child_ino)| { + !(*child_ino == ino && child_name == &entry.name) + }); + } + if entry.is_dir { + self.remove_children_recursive(ino); + } + Some(entry) + } + + fn remove_children_recursive(&self, parent_ino: u64) { + let (_, child_list) = match self.children.remove(&parent_ino) { + Some(pair) => pair, + None => return, + }; + for (_, child_ino) in &child_list { + if let Some((_, child_entry)) = self.entries.remove(child_ino) { + let mut to_remove = None; + for item in self.path_to_inode.iter() { + if *item.value() == *child_ino { + to_remove = Some(item.key().clone()); + break; + } + } + if let Some(path) = to_remove { + self.path_to_inode.remove(&path); + } + if child_entry.is_dir { + self.remove_children_recursive(*child_ino); + } + } + } + } + + pub fn get(&self, ino: u64) -> Option { + self.entries.get(&ino).map(|r| r.value().clone()) + } + + pub fn get_children(&self, parent_ino: u64) -> Vec<(String, u64)> { + self.children + .get(&parent_ino) + .map(|r| r.value().clone()) + .unwrap_or_default() + } + + pub fn lookup_child(&self, parent_ino: u64, name: &str) -> Option { + let children = self.get_children(parent_ino); + for (child_name, child_ino) in &children { + if child_name == name { + return self.get(*child_ino); + } + } + None + } + + /// 根据 inode 查找相对路径 + pub fn path_for_ino(&self, ino: u64) -> Option { + self.path_to_inode + .iter() + .find(|e| *e.value() == ino) + .map(|e| e.key().clone()) + } + + /// 更新 inode 的 remote_uri(上传成功后调用) + pub fn update_remote_uri(&self, ino: u64, uri: &str) { + if let Some(mut e) = self.entries.get_mut(&ino) { + e.remote_uri = uri.to_string(); + } + } + + /// 更新 inode 大小 + pub fn update_size(&self, ino: u64, size: u64) { + if let Some(mut e) = self.entries.get_mut(&ino) { + e.size = size; + } + } + + /// 重命名:更新 inode 的名称和父 inode,同时更新 path_to_inode + pub fn rename_inode(&self, old_rel: &str, new_rel: &str, new_parent_ino: u64, new_name: &str) { + let ino = match self.path_to_inode.remove(old_rel) { + Some((_, ino)) => ino, + None => return, + }; + // 从旧父目录子项中移除 + if let Some(mut e) = self.entries.get_mut(&ino) { + let old_parent = e.parent_ino; + e.parent_ino = new_parent_ino; + e.name = new_name.to_string(); + // 从旧父目录子项中移除 + if let Some(mut children) = self.children.get_mut(&old_parent) { + children.retain(|(_, cino)| *cino != ino); + } + // 添加到新父目录子项 + self.children + .entry(new_parent_ino) + .or_default() + .push((new_name.to_string(), ino)); + } + self.path_to_inode.insert(new_rel.to_string(), ino); + } +} + +// ========== FUSE 请求 ========== + +/// 统一请求通道:FUSE → SyncEngine +pub enum FuseRequest { + /// 按需水合(read) + Read { + inode: u64, + remote_uri: String, + offset: i64, + length: i64, + reply_tx: tokio::sync::oneshot::Sender, String>>, + }, + /// 上传文件(flush 时触发) + Upload { + inode: u64, + parent_ino: u64, + name: String, + /// 相对路径 + relative_path: String, + /// 文件数据(小文件直接内存,大文件为空需从临时文件读取) + data: Vec, + /// 大文件临时文件路径(data 为空时使用) + tmp_path: Option, + mtime_ms: i64, + /// 是否为覆盖写入(已存在远程文件的修改) + overwrite: bool, + reply_tx: tokio::sync::oneshot::Sender>, + }, + /// 创建远程目录 + Mkdir { + inode: u64, + parent_ino: u64, + name: String, + relative_path: String, + reply_tx: tokio::sync::oneshot::Sender>, + }, + /// 删除远程文件/目录 + Unlink { + inode: u64, + name: String, + is_dir: bool, + remote_uri: String, + relative_path: String, + reply_tx: tokio::sync::oneshot::Sender>, + }, + /// 重命名/移动 + Rename { + inode: u64, + old_name: String, + old_relative_path: String, + old_remote_uri: String, + new_parent_ino: u64, + new_name: String, + new_relative_path: String, + reply_tx: tokio::sync::oneshot::Sender>, + }, +} + +/// 上传结果 +pub struct UploadResult { + pub remote_uri: String, + pub remote_hash: Option, + pub size: u64, +} + +// ========== 写缓冲 ========== + +/// 单个文件的写缓冲 +enum WriteBuffer { + /// 小文件:纯内存 + Memory { data: Vec, modified: bool }, + /// 大文件:临时文件 + File { + path: std::path::PathBuf, + modified: bool, + len: u64, + }, +} + +impl WriteBuffer { + fn new_memory() -> Self { + WriteBuffer::Memory { + data: Vec::new(), + modified: false, + } + } + + fn write( + &mut self, + offset: i64, + data: &[u8], + tmp_dir: &Path, + ) -> std::result::Result<(), String> { + match self { + WriteBuffer::Memory { + data: buf, + modified, + } => { + let end = (offset as usize) + data.len(); + if end > buf.len() { + buf.resize(end, 0); + } + buf[offset as usize..end].copy_from_slice(data); + *modified = true; + // 超过阈值则切换到文件模式 + if buf.len() as u64 > MEMORY_BUFFER_THRESHOLD { + let tmp_path = tmp_dir.join(format!("fuse_write_{}", std::process::id())); + std::fs::write(&tmp_path, buf.as_slice()) + .map_err(|e| format!("写临时文件失败: {}", e))?; + let len = buf.len() as u64; + *self = WriteBuffer::File { + path: tmp_path, + modified: true, + len, + }; + } + Ok(()) + } + WriteBuffer::File { + path, + modified, + len, + } => { + use std::io::{Seek, SeekFrom, Write}; + let mut f = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|e| format!("打开临时文件失败: {}", e))?; + f.seek(SeekFrom::Start(offset as u64)) + .map_err(|e| format!("seek 失败: {}", e))?; + f.write_all(data) + .map_err(|e| format!("写临时文件失败: {}", e))?; + let new_end = (offset as u64) + data.len() as u64; + if new_end > *len { + *len = new_end; + } + *modified = true; + Ok(()) + } + } + } + + fn len(&self) -> u64 { + match self { + WriteBuffer::Memory { data, .. } => data.len() as u64, + WriteBuffer::File { len, .. } => *len, + } + } + + fn is_modified(&self) -> bool { + match self { + WriteBuffer::Memory { modified, .. } => *modified, + WriteBuffer::File { modified, .. } => *modified, + } + } + + /// 取出数据(小文件返回内存数据,大文件返回空 Vec 需从 tmp_path 读取) + fn take_data(&mut self) -> (Vec, Option) { + match std::mem::replace( + self, + WriteBuffer::Memory { + data: Vec::new(), + modified: false, + }, + ) { + WriteBuffer::Memory { data, .. } => (data, None), + WriteBuffer::File { path, .. } => { + (Vec::new(), Some(path.to_string_lossy().to_string())) + } + } + } +} + +// ========== FUSE 文件系统 ========== + +struct CloudreveFuseFs { + inode_store: Arc, + request_tx: mpsc::Sender, + runtime: tokio::runtime::Handle, + /// fh → (inode, WriteBuffer) + write_buffers: DashMap, + /// 下一个 fh 号 + next_fh: AtomicU64, + /// 远程根 URI(用于构建新文件的 remote_uri) + #[allow(dead_code)] + remote_root: String, + /// 临时文件目录 + tmp_dir: std::path::PathBuf, +} + +impl CloudreveFuseFs { + fn new( + inode_store: Arc, + request_tx: mpsc::Sender, + runtime: tokio::runtime::Handle, + remote_root: String, + tmp_dir: std::path::PathBuf, + ) -> Self { + Self { + inode_store, + request_tx, + runtime, + write_buffers: DashMap::new(), + next_fh: AtomicU64::new(1), + remote_root, + tmp_dir, + } + } + + fn alloc_fh(&self) -> u64 { + self.next_fh.fetch_add(1, Ordering::Relaxed) + } + + fn entry_to_attr(&self, entry: &InodeEntry) -> FileAttr { + let mtime = std::time::UNIX_EPOCH + std::time::Duration::from_millis(entry.mtime_ms as u64); + let kind = if entry.is_dir { + FileType::Directory + } else { + FileType::RegularFile + }; + FileAttr { + ino: entry.ino, + size: entry.size, + blocks: entry.size.div_ceil(512), + atime: mtime, + mtime, + ctime: mtime, + crtime: mtime, + kind, + perm: if entry.is_dir { 0o755 } else { 0o644 }, + nlink: if entry.is_dir { 2 } else { 1 }, + uid: unsafe { libc::getuid() }, + gid: unsafe { libc::getgid() }, + rdev: 0, + flags: 0, + blksize: 4096, + } + } + + /// 根据 parent_ino 和 name 计算相对路径 + fn relative_path(&self, parent_ino: u64, name: &str) -> String { + let parent_rel = self + .inode_store + .path_for_ino(parent_ino) + .unwrap_or_default(); + if parent_rel.is_empty() { + name.to_string() + } else { + format!("{}/{}", parent_rel, name) + } + } +} + +impl Filesystem for CloudreveFuseFs { + fn init( + &mut self, + _req: &Request, + _config: &mut fuser::KernelConfig, + ) -> std::result::Result<(), libc::c_int> { + tracing::info!("FUSE 文件系统已初始化(读写模式)"); + Ok(()) + } + + fn destroy(&mut self) { + self.write_buffers.clear(); + tracing::info!("FUSE 文件系统已销毁"); + } + + fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option, reply: ReplyAttr) { + match self.inode_store.get(ino) { + Some(entry) => reply.attr( + &std::time::Duration::from_secs(1), + &self.entry_to_attr(&entry), + ), + None => reply.error(libc::ENOENT), + } + } + + fn lookup(&mut self, _req: &Request, parent: u64, name: &std::ffi::OsStr, reply: ReplyEntry) { + let name_str = name.to_string_lossy().to_string(); + match self.inode_store.lookup_child(parent, &name_str) { + Some(entry) => reply.entry( + &std::time::Duration::from_secs(1), + &self.entry_to_attr(&entry), + 0, + ), + None => reply.error(libc::ENOENT), + } + } + + fn readdir( + &mut self, + _req: &Request, + ino: u64, + _fh: u64, + offset: i64, + mut reply: ReplyDirectory, + ) { + let entry = match self.inode_store.get(ino) { + Some(e) => e, + None => { + reply.error(libc::ENOENT); + return; + } + }; + + if !entry.is_dir { + reply.error(libc::ENOTDIR); + return; + } + + if offset == 0 && reply.add(entry.ino, 1, FileType::Directory, ".") { + reply.ok(); + return; + } + if offset <= 1 { + let parent_ino = if entry.ino == FUSE_ROOT_INO { + FUSE_ROOT_INO + } else { + entry.parent_ino + }; + if reply.add(parent_ino, 2, FileType::Directory, "..") { + reply.ok(); + return; + } + } + + let children = self.inode_store.get_children(ino); + for (i, (name, child_ino)) in children.iter().enumerate() { + let idx = (i + 2) as i64; + if idx < offset { + continue; + } + let child_entry = match self.inode_store.get(*child_ino) { + Some(e) => e, + None => continue, + }; + let kind = if child_entry.is_dir { + FileType::Directory + } else { + FileType::RegularFile + }; + if reply.add(*child_ino, idx + 1, kind, name.as_str()) { + break; + } + } + reply.ok(); + } + + // ========== 读操作 ========== + + fn open(&mut self, _req: &Request, ino: u64, flags: i32, reply: fuser::ReplyOpen) { + let fh = self.alloc_fh(); + // 如果是写打开(O_WRONLY 或 O_RDWR),初始化写缓冲 + let write_mode = (flags & libc::O_WRONLY != 0) || (flags & libc::O_RDWR != 0); + if write_mode { + self.write_buffers + .insert(fh, (ino, WriteBuffer::new_memory())); + } + reply.opened(fh, fuser::consts::FOPEN_KEEP_CACHE); + } + + fn read( + &mut self, + _req: &Request, + ino: u64, + _fh: u64, + offset: i64, + size: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyData, + ) { + // 如果有写缓冲(正在写入的文件),从写缓冲读取 + if let Some(pair) = self.write_buffers.get(&(_fh)) { + if pair.0 == ino { + match &pair.1 { + WriteBuffer::Memory { data, .. } => { + let start = offset as usize; + let end = (offset as usize + size as usize).min(data.len()); + if start < data.len() { + reply.data(&data[start..end]); + } else { + reply.data(&[]); + } + return; + } + WriteBuffer::File { path, len, .. } => { + let start = offset as u64; + let end = (start + size as u64).min(*len); + if start < *len { + match std::fs::read(path) { + Ok(data) => { + let s = start as usize; + let e = end as usize; + reply.data(&data[s..e.min(data.len())]); + } + Err(_) => reply.error(libc::EIO), + } + } else { + reply.data(&[]); + } + return; + } + } + } + } + + // 已有文件的读:走水合 + let entry = match self.inode_store.get(ino) { + Some(e) => e, + None => { + reply.error(libc::ENOENT); + return; + } + }; + if entry.is_dir { + reply.error(libc::EISDIR); + return; + } + if entry.remote_uri.is_empty() { + reply.error(libc::EIO); + return; + } + + let (tx, rx) = tokio::sync::oneshot::channel(); + let request = FuseRequest::Read { + inode: ino, + remote_uri: entry.remote_uri.clone(), + offset, + length: size as i64, + reply_tx: tx, + }; + + if self.request_tx.blocking_send(request).is_err() { + tracing::error!("FUSE read: 请求发送失败"); + reply.error(libc::EIO); + return; + } + + match self.runtime.block_on(rx) { + Ok(Ok(data)) => { + let start = offset as usize; + let end = (offset as usize + size as usize).min(data.len()); + if start < data.len() { + reply.data(&data[start..end]); + } else { + reply.data(&[]); + } + } + Ok(Err(e)) => { + tracing::error!("FUSE read: 水合失败: {}", e); + reply.error(libc::EIO); + } + Err(_) => { + tracing::error!("FUSE read: 请求被丢弃"); + reply.error(libc::EIO); + } + } + } + + // ========== 写操作 ========== + + fn write( + &mut self, + _req: &Request, + ino: u64, + fh: u64, + offset: i64, + data: &[u8], + _write_flags: u32, + _flags: i32, + _lock_owner: Option, + reply: ReplyWrite, + ) { + let mut buffer = match self.write_buffers.get_mut(&fh) { + Some(b) if b.0 == ino => b, + _ => { + // 没有写缓冲但收到 write,自动创建 + drop( + self.write_buffers + .entry(fh) + .or_insert((ino, WriteBuffer::new_memory())), + ); + self.write_buffers.get_mut(&fh).unwrap() + } + }; + + if let Err(e) = buffer.1.write(offset, data, &self.tmp_dir) { + tracing::error!("FUSE write: 缓冲失败: {}", e); + reply.error(libc::EIO); + return; + } + + // 更新 inode size + let new_size = buffer.1.len(); + self.inode_store.update_size(ino, new_size); + + reply.written(data.len() as u32); + } + + fn flush( + &mut self, + _req: &Request, + ino: u64, + fh: u64, + _lock_owner: u64, + reply: fuser::ReplyEmpty, + ) { + let mut buffer = match self.write_buffers.get_mut(&fh) { + Some(b) if b.0 == ino => b, + _ => { + reply.ok(); + return; + } + }; + + if !buffer.1.is_modified() { + reply.ok(); + return; + } + + let relative_path = match self.inode_store.path_for_ino(ino) { + Some(p) => p, + None => { + // 新创建的文件可能还没有 path,从 parent 推算 + let entry = self.inode_store.get(ino); + match entry { + Some(e) => self.relative_path(e.parent_ino, &e.name), + None => { + reply.error(libc::EIO); + return; + } + } + } + }; + + let entry = match self.inode_store.get(ino) { + Some(e) => e, + None => { + reply.error(libc::ENOENT); + return; + } + }; + + let (data, tmp_path) = buffer.1.take_data(); + let overwrite = !entry.remote_uri.is_empty(); + let parent_ino = entry.parent_ino; + let name = entry.name.clone(); + let mtime_ms = entry.mtime_ms; + + let (tx, rx) = tokio::sync::oneshot::channel(); + let request = FuseRequest::Upload { + inode: ino, + parent_ino, + name, + relative_path, + data, + tmp_path, + mtime_ms, + overwrite, + reply_tx: tx, + }; + + if self.request_tx.blocking_send(request).is_err() { + tracing::error!("FUSE flush: 上传请求发送失败"); + reply.error(libc::EIO); + return; + } + + match self.runtime.block_on(rx) { + Ok(Ok(result)) => { + self.inode_store.update_remote_uri(ino, &result.remote_uri); + self.inode_store.update_size(ino, result.size); + reply.ok(); + } + Ok(Err(e)) => { + tracing::error!("FUSE flush: 上传失败: {}", e); + reply.error(libc::EIO); + } + Err(_) => { + tracing::error!("FUSE flush: 上传请求被丢弃"); + reply.error(libc::EIO); + } + } + } + + fn release( + &mut self, + _req: &Request, + _ino: u64, + fh: u64, + _flags: i32, + _lock_owner: Option, + _flush: bool, + reply: fuser::ReplyEmpty, + ) { + if let Some((_, (_, WriteBuffer::File { path, .. }))) = self.write_buffers.remove(&fh) { + let _ = std::fs::remove_file(&path); + } + reply.ok(); + } + + fn create( + &mut self, + _req: &Request, + parent: u64, + name: &std::ffi::OsStr, + _mode: u32, + _umask: u32, + _flags: i32, + reply: fuser::ReplyCreate, + ) { + let name_str = name.to_string_lossy().to_string(); + let relative_path = self.relative_path(parent, &name_str); + let ino = self.inode_store.alloc_inode(); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + + let entry = InodeEntry { + ino, + parent_ino: parent, + name: name_str, + is_dir: false, + size: 0, + mtime_ms: now_ms, + remote_uri: String::new(), // 上传成功后填充 + remote_hash: None, + dir_loaded: false, + }; + self.inode_store + .upsert(&relative_path, parent, &entry.name.clone(), entry); + + let fh = self.alloc_fh(); + self.write_buffers + .insert(fh, (ino, WriteBuffer::new_memory())); + + let attr = self.entry_to_attr(&self.inode_store.get(ino).unwrap()); + reply.created(&std::time::Duration::from_secs(1), &attr, 0, fh, 0); + } + + fn mkdir( + &mut self, + _req: &Request, + parent: u64, + name: &std::ffi::OsStr, + _mode: u32, + _umask: u32, + reply: ReplyEntry, + ) { + let name_str = name.to_string_lossy().to_string(); + let relative_path = self.relative_path(parent, &name_str); + let ino = self.inode_store.alloc_inode(); + + let entry = InodeEntry { + ino, + parent_ino: parent, + name: name_str.clone(), + is_dir: true, + size: 0, + mtime_ms: 0, + remote_uri: String::new(), + remote_hash: None, + dir_loaded: false, + }; + self.inode_store + .upsert(&relative_path, parent, &name_str, entry); + + // 异步创建远程目录 + let (tx, rx) = tokio::sync::oneshot::channel(); + let request = FuseRequest::Mkdir { + inode: ino, + parent_ino: parent, + name: name_str, + relative_path, + reply_tx: tx, + }; + + if self.request_tx.blocking_send(request).is_err() { + tracing::error!("FUSE mkdir: 请求发送失败"); + // 本地 inode 已创建,但远程创建失败,inode 标记为无 remote_uri + } + + // 不阻塞等待远程结果,先返回本地 inode + let attr = self.entry_to_attr(&self.inode_store.get(ino).unwrap()); + reply.entry(&std::time::Duration::from_secs(1), &attr, 0); + + // 后台更新 remote_uri + let inode_store = self.inode_store.clone(); + let runtime = self.runtime.clone(); + std::thread::spawn(move || { + if let Ok(Ok(())) = runtime.block_on(rx) { + // remote_uri 在 SyncEngine 端更新 + } + let _ = inode_store; // keep Arc alive + }); + } + + fn unlink( + &mut self, + _req: &Request, + parent: u64, + name: &std::ffi::OsStr, + reply: fuser::ReplyEmpty, + ) { + let name_str = name.to_string_lossy().to_string(); + let child = match self.inode_store.lookup_child(parent, &name_str) { + Some(c) => c, + None => { + reply.error(libc::ENOENT); + return; + } + }; + if child.is_dir { + reply.error(libc::EISDIR); + return; + } + + let relative_path = self.relative_path(parent, &name_str); + let remote_uri = child.remote_uri.clone(); + let ino = child.ino; + + // 从 InodeStore 移除 + self.inode_store.remove(&relative_path); + + // 异步删除远程文件 + if !remote_uri.is_empty() { + let (tx, rx) = tokio::sync::oneshot::channel(); + let request = FuseRequest::Unlink { + inode: ino, + name: name_str, + is_dir: false, + remote_uri, + relative_path, + reply_tx: tx, + }; + if self.request_tx.blocking_send(request).is_err() { + tracing::error!("FUSE unlink: 请求发送失败"); + } else { + let runtime = self.runtime.clone(); + std::thread::spawn(move || { + let _ = runtime.block_on(rx); + }); + } + } + + reply.ok(); + } + + fn rmdir( + &mut self, + _req: &Request, + parent: u64, + name: &std::ffi::OsStr, + reply: fuser::ReplyEmpty, + ) { + let name_str = name.to_string_lossy().to_string(); + let child = match self.inode_store.lookup_child(parent, &name_str) { + Some(c) => c, + None => { + reply.error(libc::ENOENT); + return; + } + }; + if !child.is_dir { + reply.error(libc::ENOTDIR); + return; + } + + // 检查目录是否为空 + let children = self.inode_store.get_children(child.ino); + if !children.is_empty() { + reply.error(libc::ENOTEMPTY); + return; + } + + let relative_path = self.relative_path(parent, &name_str); + let remote_uri = child.remote_uri.clone(); + let ino = child.ino; + + self.inode_store.remove(&relative_path); + + if !remote_uri.is_empty() { + let (tx, rx) = tokio::sync::oneshot::channel(); + let request = FuseRequest::Unlink { + inode: ino, + name: name_str, + is_dir: true, + remote_uri, + relative_path, + reply_tx: tx, + }; + if self.request_tx.blocking_send(request).is_err() { + tracing::error!("FUSE rmdir: 请求发送失败"); + } else { + let runtime = self.runtime.clone(); + std::thread::spawn(move || { + let _ = runtime.block_on(rx); + }); + } + } + + reply.ok(); + } + + fn rename( + &mut self, + _req: &Request, + parent: u64, + name: &std::ffi::OsStr, + new_parent: u64, + new_name: &std::ffi::OsStr, + _flags: u32, + reply: fuser::ReplyEmpty, + ) { + let old_name = name.to_string_lossy().to_string(); + let new_name_str = new_name.to_string_lossy().to_string(); + let old_rel = self.relative_path(parent, &old_name); + let new_rel = self.relative_path(new_parent, &new_name_str); + + let child = match self.inode_store.lookup_child(parent, &old_name) { + Some(c) => c, + None => { + reply.error(libc::ENOENT); + return; + } + }; + let remote_uri = child.remote_uri.clone(); + let ino = child.ino; + + // 如果目标已存在,先移除 + if self + .inode_store + .lookup_child(new_parent, &new_name_str) + .is_some() + { + let target_rel = new_rel.clone(); + self.inode_store.remove(&target_rel); + } + + // 更新 InodeStore + self.inode_store + .rename_inode(&old_rel, &new_rel, new_parent, &new_name_str); + + // 异步重命名远程文件 + if !remote_uri.is_empty() { + let (tx, rx) = tokio::sync::oneshot::channel(); + let request = FuseRequest::Rename { + inode: ino, + old_name, + old_relative_path: old_rel, + old_remote_uri: remote_uri, + new_parent_ino: new_parent, + new_name: new_name_str, + new_relative_path: new_rel, + reply_tx: tx, + }; + if self.request_tx.blocking_send(request).is_err() { + tracing::error!("FUSE rename: 请求发送失败"); + } else { + let runtime = self.runtime.clone(); + std::thread::spawn(move || { + let _ = runtime.block_on(rx); + }); + } + } + + reply.ok(); + } + + fn setattr( + &mut self, + _req: &Request, + ino: u64, + _mode: Option, + uid: Option, + gid: Option, + size: Option, + _atime: Option, + _mtime: Option, + _ctime: Option, + fh: Option, + _crtime: Option, + _chgtime: Option, + _bkuptime: Option, + _flags: Option, + reply: ReplyAttr, + ) { + let _ = (uid, gid); + if self.inode_store.get(ino).is_none() { + reply.error(libc::ENOENT); + return; + } + + if let Some(new_size) = size { + self.inode_store.update_size(ino, new_size); + if let Some(fh_val) = fh { + if let Some(mut buf) = self.write_buffers.get_mut(&fh_val) { + match &mut buf.1 { + WriteBuffer::Memory { data, .. } => { + data.resize(new_size as usize, 0); + } + WriteBuffer::File { len, .. } => { + *len = new_size; + } + } + } + } + } + + let entry = self.inode_store.get(ino).unwrap(); + reply.attr( + &std::time::Duration::from_secs(1), + &self.entry_to_attr(&entry), + ); + } + + fn statfs(&mut self, _req: &Request, _ino: u64, reply: ReplyStatfs) { + reply.statfs(0, 0, 0, 0, 0, 4096, 255, 0); + } +} + +// ========== FUSE 平台适配器 ========== + +pub struct FusePlatformAdapter { + mount_path: std::path::PathBuf, + inode_store: Arc, + request_rx: std::sync::Mutex>>, + shutdown: CancellationToken, + #[allow(dead_code)] + runtime: tokio::runtime::Handle, + #[allow(dead_code)] + db: Arc, + #[allow(dead_code)] + api: Arc, + #[allow(dead_code)] + config: SyncConfig, +} + +impl FusePlatformAdapter { + pub fn new( + mount_path: &Path, + db: Arc, + api: Arc, + config: SyncConfig, + ) -> anyhow::Result { + let runtime = tokio::runtime::Handle::current(); + let inode_store = Arc::new(InodeStore::new()); + let (request_tx, request_rx) = mpsc::channel::(256); + let shutdown = CancellationToken::new(); + let shutdown_clone = shutdown.clone(); + let mount_path_buf = mount_path.to_path_buf(); + + std::fs::create_dir_all(mount_path) + .map_err(|e| anyhow::anyhow!("创建 FUSE 挂载目录失败: {}", e))?; + + // 创建临时文件目录 + let tmp_dir = config.data_dir.join("sync_core").join("tmp"); + std::fs::create_dir_all(&tmp_dir) + .map_err(|e| anyhow::anyhow!("创建临时目录失败: {}", e))?; + + let inode_store_clone = inode_store.clone(); + let mount_path_clone = mount_path_buf.clone(); + let runtime_for_thread = runtime.clone(); + let remote_root = config.remote_root.clone(); + let tmp_dir_clone = tmp_dir.clone(); + + std::thread::Builder::new() + .name("fuse-mount".to_string()) + .spawn(move || { + let fs = CloudreveFuseFs::new( + inode_store_clone, + request_tx, + runtime_for_thread, + remote_root, + tmp_dir_clone, + ); + + let options = vec![ + fuser::MountOption::FSName("cloudreve".to_string()), + fuser::MountOption::Subtype("cloudreve".to_string()), + fuser::MountOption::NoAtime, + ]; + + tracing::info!("FUSE 挂载中(读写模式): {}", mount_path_clone.display()); + + match fuser::mount2(fs, &mount_path_clone, &options) { + Ok(()) => tracing::info!("FUSE 挂载已结束: {}", mount_path_clone.display()), + Err(e) => { + if shutdown_clone.is_cancelled() { + tracing::info!("FUSE 挂载已关闭(正常退出)"); + } else { + tracing::error!("FUSE 挂载失败: {}", e); + } + } + } + }) + .map_err(|e| anyhow::anyhow!("启动 FUSE 线程失败: {}", e))?; + + tracing::info!("FusePlatformAdapter 初始化完成: {}", mount_path.display()); + + Ok(Self { + mount_path: mount_path_buf, + inode_store, + request_rx: std::sync::Mutex::new(Some(request_rx)), + shutdown, + runtime, + db, + api, + config, + }) + } + + pub fn take_request_receiver(&self) -> Option> { + self.request_rx.lock().ok().and_then(|mut rx| rx.take()) + } + + pub fn inode_store(&self) -> &Arc { + &self.inode_store + } + + #[allow(clippy::too_many_arguments)] + pub fn create_placeholder_for_remote( + &self, + parent_rel: &str, + name: &str, + relative_path: &str, + is_dir: bool, + size: u64, + remote_uri: &str, + remote_hash: Option<&str>, + mtime_ms: i64, + ) { + let parent_ino = self + .inode_store + .path_to_inode + .get(parent_rel) + .map(|r| *r.value()) + .unwrap_or(FUSE_ROOT_INO); + + let ino = self.inode_store.alloc_inode(); + let entry = InodeEntry { + ino, + parent_ino, + name: name.to_string(), + is_dir, + size, + mtime_ms, + remote_uri: remote_uri.to_string(), + remote_hash: remote_hash.map(|s| s.to_string()), + dir_loaded: false, + }; + + self.inode_store + .upsert(relative_path, parent_ino, name, entry); + tracing::debug!( + "FUSE inode 注册: {} (ino={}, dir={})", + relative_path, + ino, + is_dir + ); + } + + pub fn remove_inode(&self, relative_path: &str) { + if let Some(entry) = self.inode_store.remove(relative_path) { + tracing::debug!("FUSE inode 移除: {} (ino={})", relative_path, entry.ino); + } + } + + pub fn unmount(&self) -> anyhow::Result<()> { + self.shutdown.cancel(); + let result = std::process::Command::new("fusermount3") + .args(["-u", "-z"]) + .arg(&self.mount_path) + .status() + .or_else(|_| { + std::process::Command::new("fusermount") + .args(["-u", "-z"]) + .arg(&self.mount_path) + .status() + }); + + match result { + Ok(s) if s.success() => { + tracing::info!("FUSE 已卸载: {}", self.mount_path.display()); + Ok(()) + } + Ok(s) => { + tracing::warn!("FUSE 卸载退出码: {}", s); + Ok(()) + } + Err(e) => { + tracing::warn!("FUSE 卸载命令失败: {}", e); + Ok(()) + } + } + } + + pub fn mount_path(&self) -> &Path { + &self.mount_path + } +} diff --git a/native/sync-core/src/platform/mod.rs b/native/sync-core/src/platform/mod.rs index c63cd54..43914f9 100644 --- a/native/sync-core/src/platform/mod.rs +++ b/native/sync-core/src/platform/mod.rs @@ -1,16 +1,16 @@ -/// 平台适配器 trait — 各平台(Windows WCF / Linux / Android)实现此接口 -#[cfg(feature = "windows-cfapi")] -use async_trait::async_trait; -#[cfg(feature = "windows-cfapi")] +#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))] use crate::errors::Result; -#[cfg(feature = "windows-cfapi")] +#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))] use crate::models::{LocalFileEvent, RemoteFileEntry}; -#[cfg(feature = "windows-cfapi")] +/// 平台适配器 trait — 各平台(Windows WCF / Linux FUSE / Android)实现此接口 +#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))] +use async_trait::async_trait; +#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))] use std::path::Path; -#[cfg(feature = "windows-cfapi")] +#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))] use tokio::sync::mpsc; -#[cfg(feature = "windows-cfapi")] +#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))] #[async_trait] pub trait PlatformAdapter: Send + Sync { /// 初始同步后的平台初始化 @@ -34,3 +34,6 @@ pub trait PlatformAdapter: Send + Sync { #[cfg(feature = "windows-cfapi")] pub mod wcf; + +#[cfg(feature = "linux-fuse")] +pub mod fuse; diff --git a/native/sync-core/src/sync_db.rs b/native/sync-core/src/sync_db.rs index 0ea3267..fcbd790 100644 --- a/native/sync-core/src/sync_db.rs +++ b/native/sync-core/src/sync_db.rs @@ -175,7 +175,9 @@ impl SyncDb { // 添加 task_item 唯一索引(去重:保留 id 最小的记录) // 先清理重复数据,再建唯一索引 let existing: Vec = conn - .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_task_item_unique'")? + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_task_item_unique'", + )? .query_map([], |r| r.get(0))? .filter_map(|r| r.ok()) .collect(); @@ -200,7 +202,8 @@ impl SyncDb { SyncMode::Full => "full", SyncMode::UploadOnly => "upload_only", SyncMode::DownloadOnly => "download_only", - SyncMode::Album => "album", + SyncMode::AlbumUpload => "album_upload", + SyncMode::AlbumDownload => "album_download", SyncMode::MirrorWcf => "mirror_wcf", }; @@ -327,12 +330,38 @@ impl SyncDb { Ok(result) } - /// 查询所有占位符文件映射(用于 WCF 退出时清理) - #[cfg(feature = "windows-cfapi")] - pub async fn list_placeholder_mappings( + /// 删除指定 remote_uri 的文件映射 + pub async fn delete_mapping_by_remote_uri( &self, sync_root_id: &str, - ) -> Result> { + remote_uri: &str, + ) -> Result<()> { + let conn = self.write_conn.lock().await; + conn.execute( + "DELETE FROM file_mapping WHERE sync_root_id = ?1 AND remote_uri = ?2", + rusqlite::params![sync_root_id, remote_uri], + )?; + Ok(()) + } + + /// 更新文件映射的 remote_uri(重命名/移动时使用) + pub async fn update_mapping_remote_uri( + &self, + sync_root_id: &str, + old_remote_uri: &str, + new_remote_uri: &str, + ) -> Result<()> { + let conn = self.write_conn.lock().await; + conn.execute( + "UPDATE file_mapping SET remote_uri = ?3 WHERE sync_root_id = ?1 AND remote_uri = ?2", + rusqlite::params![sync_root_id, old_remote_uri, new_remote_uri], + )?; + Ok(()) + } + + /// 查询所有占位符文件映射(用于 WCF 退出时清理) + #[cfg(feature = "windows-cfapi")] + pub async fn list_placeholder_mappings(&self, sync_root_id: &str) -> Result> { let pool = self.read_pool.clone(); let sync_root_id = sync_root_id.to_string(); @@ -345,26 +374,30 @@ impl SyncDb { FROM file_mapping WHERE sync_root_id = ?1 AND is_placeholder = 1", )?; - let mappings = stmt.query_map(rusqlite::params![sync_root_id], |row| { - Ok(FileMapping { - id: row.get(0)?, - sync_root_id: row.get(1)?, - local_path: std::path::PathBuf::from(row.get::<_, String>(2)?), - remote_uri: row.get(3)?, - remote_file_id: row.get(4)?, - local_hash: row.get(5)?, - remote_hash: row.get(6)?, - local_mtime: row.get(7)?, - remote_mtime: row.get(8)?, - local_size: row.get(9)?, - remote_size: row.get(10)?, - sync_status: parse_sync_status(&row.get::<_, String>(11)?), - is_placeholder: row.get::<_, i32>(12)? != 0, - }) - })?.filter_map(|m| m.ok()).collect(); + let mappings = stmt + .query_map(rusqlite::params![sync_root_id], |row| { + Ok(FileMapping { + id: row.get(0)?, + sync_root_id: row.get(1)?, + local_path: std::path::PathBuf::from(row.get::<_, String>(2)?), + remote_uri: row.get(3)?, + remote_file_id: row.get(4)?, + local_hash: row.get(5)?, + remote_hash: row.get(6)?, + local_mtime: row.get(7)?, + remote_mtime: row.get(8)?, + local_size: row.get(9)?, + remote_size: row.get(10)?, + sync_status: parse_sync_status(&row.get::<_, String>(11)?), + is_placeholder: row.get::<_, i32>(12)? != 0, + }) + })? + .filter_map(|m| m.ok()) + .collect(); Ok(mappings) - }).await??; + }) + .await??; Ok(result) } @@ -727,6 +760,46 @@ impl SyncDb { Ok(conn.last_insert_rowid()) } + /// 创建独立的 task + task_item 记录(用于 WCF 等绕过 WorkerPool 的操作统计) + pub async fn record_standalone_task_item( + &self, + trigger: &WorkerTrigger, + item: &SyncTaskItem, + ) -> Result<()> { + let conn = self.write_conn.lock().await; + let tx = conn.unchecked_transaction()?; + tx.execute( + "INSERT INTO sync_task (id, trigger, total_count, completed_count, failed_count, status, created_at, updated_at, finished_at) + VALUES (?1, ?2, 1, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + item.task_id, + trigger.as_str(), + if item.status == TaskItemStatus::Failed { 0 } else { 1 }, + if item.status == TaskItemStatus::Failed { 1 } else { 0 }, + if item.status == TaskItemStatus::Failed { WorkerStatus::Failed.as_str() } else { WorkerStatus::Completed.as_str() }, + item.created_at, + item.updated_at, + item.updated_at, + ], + )?; + tx.execute( + "INSERT INTO sync_task_item (task_id, relative_path, action_type, status, file_size, error_message, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + item.task_id, + item.relative_path, + item.action_type.as_str(), + item.status.as_str(), + item.file_size, + item.error_message, + item.created_at, + item.updated_at, + ], + )?; + tx.commit()?; + Ok(()) + } + /// 批量插入 task_item(单次持锁,用事务包裹) pub async fn create_sync_task_items_batch(&self, items: &[SyncTaskItem]) -> Result<()> { let conn = self.write_conn.lock().await; @@ -848,6 +921,63 @@ impl SyncDb { Ok(result) } + /// 从 DB 聚合累积统计(跨所有同步任务) + /// downloaded 包含 download + hydration(create_placeholder 不计入,仅重建映射非实际下载) + pub async fn get_cum_stats(&self) -> Result { + let pool = self.read_pool.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + let conn = pool.get()?; + + let uploaded: u32 = conn.query_row( + "SELECT COUNT(*) FROM sync_task_item WHERE action_type = 'upload' AND status = 'completed'", + [], |r| r.get(0), + ).unwrap_or(0); + + let downloaded: u32 = conn.query_row( + "SELECT COUNT(*) FROM sync_task_item WHERE action_type IN ('download', 'hydration') AND status = 'completed'", + [], |r| r.get(0), + ).unwrap_or(0); + + let renamed: u32 = conn.query_row( + "SELECT COUNT(*) FROM sync_task_item WHERE action_type = 'rename' AND status = 'completed'", + [], |r| r.get(0), + ).unwrap_or(0); + + let moved: u32 = conn.query_row( + "SELECT COUNT(*) FROM sync_task_item WHERE action_type = 'move' AND status = 'completed'", + [], |r| r.get(0), + ).unwrap_or(0); + + let failed: u32 = conn.query_row( + "SELECT COUNT(*) FROM sync_task_item WHERE status = 'failed'", + [], |r| r.get(0), + ).unwrap_or(0); + + let conflicts: u32 = conn.query_row( + "SELECT COUNT(*) FROM sync_task_item WHERE action_type = 'conflict_resolve' AND status = 'completed'", + [], |r| r.get(0), + ).unwrap_or(0); + + let deleted_local: u32 = conn.query_row( + "SELECT COUNT(*) FROM sync_task_item WHERE action_type = 'delete_local' AND status = 'completed'", + [], |r| r.get(0), + ).unwrap_or(0); + + let deleted_remote: u32 = conn.query_row( + "SELECT COUNT(*) FROM sync_task_item WHERE action_type = 'delete_remote' AND status = 'completed'", + [], |r| r.get(0), + ).unwrap_or(0); + + let skipped: u32 = conn.query_row( + "SELECT COUNT(*) FROM sync_task_item WHERE status = 'skipped'", + [], |r| r.get(0), + ).unwrap_or(0); + + Ok(SyncCumStats { uploaded, downloaded, renamed, moved, failed, conflicts, deleted_local, deleted_remote, skipped }) + }).await??; + Ok(result) + } + /// 清空所有同步数据(保留 sync_root 和 album_sync_record) pub async fn reset_sync_data(&self) -> Result<()> { let conn = self.write_conn.lock().await; @@ -933,6 +1063,8 @@ fn sync_task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { "initial_sync" => WorkerTrigger::InitialSync, "continuous" => WorkerTrigger::Continuous, "manual" => WorkerTrigger::Manual, + "hydration" => WorkerTrigger::Hydration, + "wcf_event" => WorkerTrigger::WcfEvent, _ => WorkerTrigger::Manual, }; let status_str: String = row.get(5)?; diff --git a/native/sync-core/src/sync_engine/album.rs b/native/sync-core/src/sync_engine/album.rs index 2229496..96082f5 100644 --- a/native/sync-core/src/sync_engine/album.rs +++ b/native/sync-core/src/sync_engine/album.rs @@ -7,29 +7,54 @@ use super::SyncEngine; impl SyncEngine { pub async fn sync_album(&self, album_paths: Vec, remote_dcim_uri: &str) -> Result<()> { let synced = self.db.get_album_sync_records().await?; - let new_photos: Vec<_> = album_paths.iter().filter(|p| !synced.contains_key(*p)).collect(); + let new_photos: Vec<_> = album_paths + .iter() + .filter(|p| !synced.contains_key(*p)) + .collect(); let total = new_photos.len(); - if total == 0 { return Ok(()); } + if total == 0 { + return Ok(()); + } for (i, photo_path) in new_photos.iter().enumerate() { let local_path = Path::new(photo_path); - let file_name = local_path.file_name() + let file_name = local_path + .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| format!("photo_{}", i)); match tokio::fs::metadata(photo_path).await { Ok(metadata) => { let file_size = metadata.len(); - match self.api.create_upload_session(remote_dcim_uri, file_size, false, None, None, None).await { + match self + .api + .create_upload_session(remote_dcim_uri, file_size, false, None, None, None) + .await + { Ok(session) => { - match crate::uploader::upload_file_chunked(&self.api, local_path, &session, "album").await { + match crate::uploader::upload_file_chunked( + &self.api, local_path, &session, "album", + ) + .await + { Ok(_) => { let remote_uri = format!("{}/{}", remote_dcim_uri, file_name); - let hash = crate::utils::quick_hash(local_path, file_size).await.unwrap_or_default(); - if let Err(e) = self.db.add_album_sync_record(photo_path, &remote_uri, &hash).await { + let hash = crate::utils::quick_hash(local_path, file_size) + .await + .unwrap_or_default(); + if let Err(e) = self + .db + .add_album_sync_record(photo_path, &remote_uri, &hash) + .await + { tracing::warn!("记录同步状态失败: {}", e); } - tracing::info!("照片上传完成 ({}/{}): {}", i + 1, total, file_name); + tracing::info!( + "照片上传完成 ({}/{}): {}", + i + 1, + total, + file_name + ); } Err(e) => tracing::error!("上传照片失败 {}: {}", file_name, e), } @@ -47,17 +72,47 @@ impl SyncEngine { let files = self.api.list_files_page(base_uri, 0, 200, None).await?; let dcim_exists = files.files.iter().any(|f| f.name == "DCIM" && f.is_dir); let pictures_exists = files.files.iter().any(|f| f.name == "Pictures" && f.is_dir); + let dcim_uri = if dcim_exists { + Some(format!("{}/DCIM", base_uri)) + } else { + None + }; + + // 检查 DCIM/Camera 子目录 + let camera_exists = if let Some(ref dcim_uri) = dcim_uri { + let dcim_files = self.api.list_files_page(dcim_uri, 0, 200, None).await?; + dcim_files + .files + .iter() + .any(|f| f.name == "Camera" && f.is_dir) + } else { + false + }; + Ok(CloudAlbumCheckResult { dcim_exists, pictures_exists, - dcim_uri: if dcim_exists { Some(format!("{}/DCIM", base_uri)) } else { None }, - pictures_uri: if pictures_exists { Some(format!("{}/Pictures", base_uri)) } else { None }, + dcim_uri, + pictures_uri: if pictures_exists { + Some(format!("{}/Pictures", base_uri)) + } else { + None + }, + camera_exists, + camera_uri: if camera_exists { + Some(format!("{}/DCIM/Camera", base_uri)) + } else { + None + }, }) } pub async fn create_album_dirs(&self, base_uri: &str) -> Result<()> { self.api.create_directory(base_uri, "DCIM").await?; self.api.create_directory(base_uri, "Pictures").await?; + // 创建 DCIM/Camera 子目录 + let dcim_uri = format!("{}/DCIM", base_uri); + self.api.create_directory(&dcim_uri, "Camera").await?; Ok(()) } } diff --git a/native/sync-core/src/sync_engine/continuous_sync.rs b/native/sync-core/src/sync_engine/continuous_sync.rs index 9fd517a..5e4b7d6 100644 --- a/native/sync-core/src/sync_engine/continuous_sync.rs +++ b/native/sync-core/src/sync_engine/continuous_sync.rs @@ -7,27 +7,37 @@ use super::SyncEngine; impl SyncEngine { /// 持续同步:双事件源驱动 (SSE + 本地文件监听),按 sync_mode 选择事件源 pub async fn run_continuous(&self) -> Result<()> { - let event_handler = EventHandler::new( - self.api.clone(), - self.api.client_id().to_string(), - ); + let event_handler = EventHandler::new(self.api.clone(), self.api.client_id().to_string()); let (local_root, remote_root, sync_mode) = { let config = self.config.read().await; - (config.local_root.clone(), config.remote_root.clone(), config.sync_mode.clone()) + ( + config.local_root.clone(), + config.remote_root.clone(), + config.sync_mode.clone(), + ) }; - // 仅 DownloadOnly、Full 和 MirrorWcf 订阅 SSE - let mut remote_rx = if matches!(sync_mode, SyncMode::DownloadOnly | SyncMode::Full | SyncMode::MirrorWcf) { + // 仅 DownloadOnly、Full、MirrorWcf、AlbumDownload 订阅 SSE + let mut remote_rx = if matches!( + sync_mode, + SyncMode::DownloadOnly | SyncMode::Full | SyncMode::MirrorWcf | SyncMode::AlbumDownload + ) { Some(event_handler.subscribe_sse(&remote_root).await?) } else { tracing::info!("仅上传模式: 不订阅 SSE 远程事件"); None }; - // 仅 UploadOnly、Full 和 MirrorWcf 启动本地文件监听 - let mut local_rx = if matches!(sync_mode, SyncMode::UploadOnly | SyncMode::Full | SyncMode::MirrorWcf) { - Some(spawn_local_watcher(&local_root, self.shutdown_token.lock().unwrap().clone())) + // 仅 UploadOnly、Full、MirrorWcf、AlbumUpload 启动本地文件监听 + let mut local_rx = if matches!( + sync_mode, + SyncMode::UploadOnly | SyncMode::Full | SyncMode::MirrorWcf | SyncMode::AlbumUpload + ) { + Some(spawn_local_watcher( + &local_root, + self.shutdown_token.lock().unwrap().clone(), + )) } else { tracing::info!("仅下载模式: 不启动本地文件监听"); None @@ -46,9 +56,21 @@ impl SyncEngine { #[cfg(not(feature = "windows-cfapi"))] let _wcf_fetch_rx: Option<()> = None; - let mut debounce = crate::event_handler::EventDebouncer::new( - std::time::Duration::from_millis(500), - ); + // MirrorFUSE: 取走 FUSE 请求接收端 + #[cfg(feature = "linux-fuse")] + let mut fuse_request_rx = if matches!(sync_mode, SyncMode::MirrorWcf) { + self.fuse_request_rx + .lock() + .ok() + .and_then(|mut rx| rx.take()) + } else { + None + }; + #[cfg(not(feature = "linux-fuse"))] + let _fuse_request_rx: Option<()> = None; + + let mut debounce = + crate::event_handler::EventDebouncer::new(std::time::Duration::from_millis(500)); let shutdown_token = self.shutdown_token.lock().unwrap().clone(); loop { @@ -109,6 +131,29 @@ impl SyncEngine { } } + // FUSE 请求(仅 MirrorFUSE) + request = async { + #[cfg(feature = "linux-fuse")] + { + match &mut fuse_request_rx { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } + #[cfg(not(feature = "linux-fuse"))] + { + let _: Option<()> = std::future::pending().await; + None::<()> + } + } => { + if let Some(req) = request { + #[cfg(feature = "linux-fuse")] + self.handle_fuse_request(req, &local_root).await; + #[cfg(not(feature = "linux-fuse"))] + let _: () = req; + } + } + // 定期心跳 _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { tracing::trace!("持续同步心跳"); @@ -130,9 +175,9 @@ fn spawn_local_watcher( let watch_root = watch_root.to_path_buf(); std::thread::spawn(move || { - use notify_debouncer_full::notify::{RecursiveMode, EventKind}; - use notify_debouncer_full::notify::event::{ModifyKind, RenameMode}; use notify_debouncer_full::new_debouncer; + use notify_debouncer_full::notify::event::{ModifyKind, RenameMode}; + use notify_debouncer_full::notify::{EventKind, RecursiveMode}; let tx = local_tx.clone(); let shutdown = shutdown_token.clone(); @@ -140,51 +185,54 @@ fn spawn_local_watcher( let mut debouncer = match new_debouncer( std::time::Duration::from_millis(500), None, - move |result: notify_debouncer_full::DebounceEventResult| { - match result { - Ok(events) => { - for event in events { - if shutdown.is_cancelled() { return; } - let kind = event.kind; - let paths = &event.paths; + move |result: notify_debouncer_full::DebounceEventResult| match result { + Ok(events) => { + for event in events { + if shutdown.is_cancelled() { + return; + } + let kind = event.kind; + let paths = &event.paths; - let filtered: Vec<_> = paths.iter() - .filter(|p| !p.extension().map(|e| e == "sync_tmp").unwrap_or(false)) - .cloned() - .collect(); - if filtered.is_empty() { continue; } + let filtered: Vec<_> = paths + .iter() + .filter(|p| !p.extension().map(|e| e == "sync_tmp").unwrap_or(false)) + .cloned() + .collect(); + if filtered.is_empty() { + continue; + } - match kind { - EventKind::Create(_) => { - let _ = tx.blocking_send(LocalFileEvent::Created(filtered)); - } - EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {} - EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {} - EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => { - if filtered.len() == 2 { - let _ = tx.blocking_send(LocalFileEvent::Renamed { - old_paths: vec![filtered[0].clone()], - new_paths: vec![filtered[1].clone()], - }); - } - } - EventKind::Modify(ModifyKind::Name(RenameMode::Other)) => { - let _ = tx.blocking_send(LocalFileEvent::Modified(filtered)); - } - EventKind::Modify(_) => { - let _ = tx.blocking_send(LocalFileEvent::Modified(filtered)); - } - EventKind::Remove(_) => { - let _ = tx.blocking_send(LocalFileEvent::Deleted(filtered)); - } - _ => {} + match kind { + EventKind::Create(_) => { + let _ = tx.blocking_send(LocalFileEvent::Created(filtered)); } + EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {} + EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {} + EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => { + if filtered.len() == 2 { + let _ = tx.blocking_send(LocalFileEvent::Renamed { + old_paths: vec![filtered[0].clone()], + new_paths: vec![filtered[1].clone()], + }); + } + } + EventKind::Modify(ModifyKind::Name(RenameMode::Other)) => { + let _ = tx.blocking_send(LocalFileEvent::Modified(filtered)); + } + EventKind::Modify(_) => { + let _ = tx.blocking_send(LocalFileEvent::Modified(filtered)); + } + EventKind::Remove(_) => { + let _ = tx.blocking_send(LocalFileEvent::Deleted(filtered)); + } + _ => {} } } - Err(errors) => { - for e in errors { - tracing::warn!("文件监听去抖错误: {}", e); - } + } + Err(errors) => { + for e in errors { + tracing::warn!("文件监听去抖错误: {}", e); } } }, diff --git a/native/sync-core/src/sync_engine/fuse.rs b/native/sync-core/src/sync_engine/fuse.rs new file mode 100644 index 0000000..af4fc35 --- /dev/null +++ b/native/sync-core/src/sync_engine/fuse.rs @@ -0,0 +1,657 @@ +//! FUSE (Linux) 相关的 SyncEngine 方法 +//! 仅在 linux-fuse feature 启用时编译 + +use crate::models::*; + +use super::SyncEngine; + +#[cfg(feature = "linux-fuse")] +impl SyncEngine { + /// 处理 FUSE 请求(统一分发) + pub(crate) async fn handle_fuse_request( + &self, + request: crate::platform::fuse::FuseRequest, + local_root: &std::path::Path, + ) { + use crate::platform::fuse::FuseRequest; + match request { + FuseRequest::Read { + inode, + remote_uri, + offset, + length, + reply_tx, + } => { + self.handle_fuse_read(inode, &remote_uri, offset, length, reply_tx, local_root) + .await; + } + FuseRequest::Upload { + inode, + parent_ino, + name, + relative_path, + data, + tmp_path, + mtime_ms, + overwrite, + reply_tx, + } => { + self.handle_fuse_upload( + inode, + parent_ino, + &name, + &relative_path, + data, + tmp_path.as_deref(), + mtime_ms, + overwrite, + reply_tx, + ) + .await; + } + FuseRequest::Mkdir { + inode, + parent_ino, + name, + relative_path, + reply_tx, + } => { + self.handle_fuse_mkdir(inode, parent_ino, &name, &relative_path, reply_tx) + .await; + } + FuseRequest::Unlink { + inode, + name, + is_dir, + remote_uri, + relative_path, + reply_tx, + } => { + self.handle_fuse_unlink( + inode, + &name, + is_dir, + &remote_uri, + &relative_path, + reply_tx, + ) + .await; + } + FuseRequest::Rename { + inode, + old_name, + old_relative_path, + old_remote_uri, + new_parent_ino, + new_name, + new_relative_path, + reply_tx, + } => { + self.handle_fuse_rename( + inode, + &old_name, + &old_relative_path, + &old_remote_uri, + new_parent_ino, + &new_name, + &new_relative_path, + reply_tx, + ) + .await; + } + } + } + + /// MirrorFUSE: 处理 FUSE read 水合请求(按需下载) + pub(crate) async fn handle_fuse_read( + &self, + inode: u64, + remote_uri: &str, + offset: i64, + length: i64, + reply_tx: tokio::sync::oneshot::Sender, String>>, + _local_root: &std::path::Path, + ) { + tracing::debug!( + "FUSE 水合请求: ino={}, uri={}, offset={}, length={}", + inode, + remote_uri, + offset, + length + ); + + let root_id = match &self.sync_root_id { + Some(id) => id.clone(), + None => { + let _ = reply_tx.send(Err("sync_root_id 为空".to_string())); + return; + } + }; + + let remote_uri_owned = remote_uri.to_string(); + + let now = std::time::Instant::now(); + self.hydration_cache + .retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < 300); + + let data = if let Some(cached) = self.hydration_cache.get(&remote_uri_owned) { + tracing::debug!("FUSE 水合缓存命中: {}", remote_uri_owned); + cached.0.clone() + } else { + tracing::info!("FUSE 水合下载: {}", remote_uri_owned); + let config = self.snapshot_worker_config().await; + + let download_result = async { + let urls = self.api.get_download_url(&[&remote_uri_owned]).await; + let urls = match urls { + Ok(u) => u, + Err(crate::errors::SyncError::Auth(_)) => { + tracing::info!("FUSE 水合: token 过期,尝试刷新后重试"); + self.api.refresh_access_token().await?; + self.api.get_download_url(&[&remote_uri_owned]).await? + } + Err(e) => return Err(e), + }; + let download_url = urls.into_iter().next().ok_or_else(|| { + crate::errors::SyncError::Network("获取下载 URL 返回空列表".into()) + })?; + + let data = crate::downloader::download_to_buffer( + &self.api, + &download_url, + config.bandwidth_limit, + ) + .await?; + + Ok::, crate::errors::SyncError>(data) + } + .await; + + match download_result { + Ok(data) => { + self.hydration_cache.insert( + remote_uri_owned.clone(), + (data.clone(), std::time::Instant::now()), + ); + data + } + Err(e) => { + tracing::error!("FUSE 水合下载失败: {}: {}", remote_uri_owned, e); + let _ = reply_tx.send(Err(format!("下载失败: {}", e))); + return; + } + } + }; + + if let Ok(Some(mapping)) = self + .db + .find_mapping_by_remote_uri(&root_id, &remote_uri_owned) + .await + { + let _ = self + .db + .upsert_file_mapping(&FileMapping { + id: mapping.id, + sync_root_id: mapping.sync_root_id, + local_path: mapping.local_path.clone(), + remote_uri: mapping.remote_uri.clone(), + remote_file_id: mapping.remote_file_id.clone(), + local_hash: None, + remote_hash: mapping.remote_hash.clone(), + local_mtime: mapping.local_mtime, + remote_mtime: mapping.remote_mtime, + local_size: None, + remote_size: Some(data.len() as u64), + sync_status: SyncFileStatus::Synced, + is_placeholder: false, + }) + .await; + } + + let _ = reply_tx.send(Ok(data)); + } + + /// FUSE 上传:将写入的文件上传到云端 + #[allow(clippy::too_many_arguments)] + async fn handle_fuse_upload( + &self, + inode: u64, + _parent_ino: u64, + name: &str, + relative_path: &str, + data: Vec, + tmp_path: Option<&str>, + _mtime_ms: i64, + overwrite: bool, + reply_tx: tokio::sync::oneshot::Sender>, + ) { + let root_id = match &self.sync_root_id { + Some(id) => id.clone(), + None => { + let _ = reply_tx.send(Err("sync_root_id 为空".to_string())); + return; + } + }; + + let config = self.snapshot_worker_config().await; + let remote_root = &config.remote_root; + let file_uri = format!("{}/{}", remote_root, relative_path); + + tracing::info!( + "FUSE 上传: {} ({}bytes, overwrite={})", + relative_path, + data.len(), + overwrite + ); + + // 确保远程父目录存在 + if let Some(parent) = std::path::PathBuf::from(relative_path).parent() { + let parent_str = parent.to_string_lossy().to_string(); + if !parent_str.is_empty() { + if let Err(e) = crate::uploader::ensure_remote_dirs( + "fuse", + remote_root, + &parent_str, + &self.api, + &self.ensured_dirs, + ) + .await + { + tracing::warn!("FUSE 上传: 确保远程父目录失败 {}: {}", parent_str, e); + } + } + } + + // 读取文件数据 + let file_data = if !data.is_empty() { + data + } else if let Some(tmp) = tmp_path { + match tokio::fs::read(tmp).await { + Ok(d) => d, + Err(e) => { + let _ = reply_tx.send(Err(format!("读取临时文件失败: {}", e))); + return; + } + } + } else { + Vec::new() + }; + + let file_size = file_data.len() as u64; + + // 创建上传会话 + let session = match crate::uploader::retry_upload_session( + "fuse", &file_uri, file_size, 3, overwrite, None, None, None, &self.api, + ) + .await + { + Ok(s) => s, + Err(e) => { + let _ = reply_tx.send(Err(format!("创建上传会话失败: {}", e))); + return; + } + }; + + let chunk_size = session.chunk_size as usize; + + // 逐块上传 + let mut offset = 0usize; + let mut index: u32 = 0; + while offset < file_data.len() { + let end = (offset + chunk_size).min(file_data.len()); + let chunk = &file_data[offset..end]; + let mut chunk_retries = 0u32; + loop { + match self + .api + .upload_chunk(&session, index, chunk, file_size, "fuse") + .await + { + Ok(_) => break, + Err(e) if chunk_retries < 3 => { + chunk_retries += 1; + tracing::warn!("FUSE 上传重试 ({}/{}): {}", chunk_retries, 3, e); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + Err(e) => { + let _ = reply_tx.send(Err(format!("上传分片失败: {}", e))); + return; + } + } + } + offset = end; + index += 1; + } + + // 远程存储策略回调 + if session.is_remote_storage() { + if let Err(e) = self.api.callback_upload_complete(&session, "fuse").await { + tracing::warn!("FUSE 上传完成回调失败: {}", e); + } + } + + // 获取远程文件信息 + let (remote_file_id, remote_hash) = match self.api.get_file_info(&file_uri).await { + Ok(info) => (info.file_id.clone(), info.hash.clone()), + Err(e) => { + tracing::warn!("FUSE 上传后获取文件信息失败: {}", e); + (None, None) + } + }; + + // 更新 DB mapping + let _ = self + .db + .upsert_file_mapping(&FileMapping { + id: 0, + sync_root_id: root_id, + local_path: std::path::PathBuf::from(relative_path), + remote_uri: file_uri.clone(), + remote_file_id, + local_hash: None, + remote_hash: remote_hash.clone(), + local_mtime: Some(_mtime_ms), + remote_mtime: Some(_mtime_ms), + local_size: Some(file_size), + remote_size: Some(file_size), + sync_status: SyncFileStatus::Synced, + is_placeholder: false, + }) + .await; + + // 抑制 SSE 回弹 + self.suppress_paths + .insert(relative_path.to_string(), std::time::Instant::now()); + + tracing::info!( + "FUSE 上传完成: {} → {} ({}bytes)", + name, + file_uri, + file_size + ); + + let _ = reply_tx.send(Ok(crate::platform::fuse::UploadResult { + remote_uri: file_uri, + remote_hash, + size: file_size, + })); + + let _ = inode; // used for logging + } + + /// FUSE 创建远程目录 + async fn handle_fuse_mkdir( + &self, + _inode: u64, + _parent_ino: u64, + name: &str, + relative_path: &str, + reply_tx: tokio::sync::oneshot::Sender>, + ) { + let root_id = match &self.sync_root_id { + Some(id) => id.clone(), + None => { + let _ = reply_tx.send(Err("sync_root_id 为空".to_string())); + return; + } + }; + + let config = self.snapshot_worker_config().await; + let parent_rel = std::path::PathBuf::from(relative_path) + .parent() + .map(|p| crate::utils::normalize_path(&p.to_string_lossy())) + .unwrap_or_default(); + let parent_uri = if parent_rel.is_empty() { + config.remote_root.clone() + } else { + format!("{}/{}", config.remote_root, parent_rel) + }; + + tracing::info!("FUSE 创建目录: {} (parent_uri={})", name, parent_uri); + + match self.api.create_directory(&parent_uri, name).await { + Ok(remote_entry) => { + let remote_uri = remote_entry.uri.clone(); + + // 更新 InodeStore 中的 remote_uri(在 await 之前释放锁) + { + let adapter = match self.fuse_adapter.lock() { + Ok(guard) => guard, + Err(e) => { + let _ = reply_tx.send(Err(format!("锁失败: {}", e))); + return; + } + }; + if let Some(ref fuse) = *adapter { + fuse.inode_store().update_remote_uri(_inode, &remote_uri); + } + } + + // 更新 DB mapping + let _ = self + .db + .upsert_file_mapping(&FileMapping { + id: 0, + sync_root_id: root_id, + local_path: std::path::PathBuf::from(relative_path), + remote_uri: remote_uri.clone(), + remote_file_id: remote_entry.file_id.clone(), + local_hash: None, + remote_hash: remote_entry.hash.clone(), + local_mtime: None, + remote_mtime: Some(remote_entry.mtime_ms), + local_size: None, + remote_size: Some(0), + sync_status: SyncFileStatus::Synced, + is_placeholder: false, + }) + .await; + + // 抑制 SSE 回弹 + self.suppress_paths + .insert(relative_path.to_string(), std::time::Instant::now()); + + tracing::info!("FUSE 目录创建成功: {} → {}", name, remote_uri); + let _ = reply_tx.send(Ok(())); + } + Err(e) => { + tracing::error!("FUSE 创建目录失败: {}: {}", name, e); + let _ = reply_tx.send(Err(format!("创建目录失败: {}", e))); + } + } + } + + /// FUSE 删除远程文件/目录 + async fn handle_fuse_unlink( + &self, + _inode: u64, + name: &str, + _is_dir: bool, + remote_uri: &str, + relative_path: &str, + reply_tx: tokio::sync::oneshot::Sender>, + ) { + let root_id = match &self.sync_root_id { + Some(id) => id.clone(), + None => { + let _ = reply_tx.send(Err("sync_root_id 为空".to_string())); + return; + } + }; + + tracing::info!("FUSE 删除: {} ({})", name, remote_uri); + + match self.api.delete_files(&[remote_uri]).await { + Ok(()) => { + // 删除 DB mapping + let _ = self + .db + .delete_mapping_by_remote_uri(&root_id, remote_uri) + .await; + + // 抑制 SSE 回弹 + self.suppress_paths + .insert(relative_path.to_string(), std::time::Instant::now()); + + tracing::info!("FUSE 删除成功: {}", name); + let _ = reply_tx.send(Ok(())); + } + Err(e) => { + tracing::error!("FUSE 删除失败: {}: {}", name, e); + let _ = reply_tx.send(Err(format!("删除失败: {}", e))); + } + } + } + + /// FUSE 重命名/移动 + #[allow(clippy::too_many_arguments)] + async fn handle_fuse_rename( + &self, + _inode: u64, + old_name: &str, + old_relative_path: &str, + old_remote_uri: &str, + new_parent_ino: u64, + new_name: &str, + new_relative_path: &str, + reply_tx: tokio::sync::oneshot::Sender>, + ) { + let root_id = match &self.sync_root_id { + Some(id) => id.clone(), + None => { + let _ = reply_tx.send(Err("sync_root_id 为空".to_string())); + return; + } + }; + + let config = self.snapshot_worker_config().await; + let _ = (new_parent_ino, new_name); + + // 判断是同目录重命名还是跨目录移动 + let old_parent_rel = std::path::PathBuf::from(old_relative_path) + .parent() + .map(|p| crate::utils::normalize_path(&p.to_string_lossy())) + .unwrap_or_default(); + let new_parent_rel = std::path::PathBuf::from(new_relative_path) + .parent() + .map(|p| crate::utils::normalize_path(&p.to_string_lossy())) + .unwrap_or_default(); + + let result = if old_parent_rel == new_parent_rel { + // 同目录重命名 + tracing::info!("FUSE 重命名: {} → {}", old_name, new_name); + self.api.rename_file(old_remote_uri, new_name).await + } else { + // 跨目录移动 + let dst_uri = if new_parent_rel.is_empty() { + config.remote_root.clone() + } else { + format!("{}/{}", config.remote_root, new_parent_rel) + }; + tracing::info!("FUSE 移动: {} → {}", old_remote_uri, dst_uri); + self.api + .move_files(&[old_remote_uri], &dst_uri, false) + .await + }; + + match result { + Ok(()) => { + let new_remote_uri = format!("{}/{}", config.remote_root, new_relative_path); + + // 更新 InodeStore 中的 remote_uri(在 await 之前释放锁) + { + let adapter = match self.fuse_adapter.lock() { + Ok(guard) => guard, + Err(e) => { + let _ = reply_tx.send(Err(format!("锁失败: {}", e))); + return; + } + }; + if let Some(ref fuse) = *adapter { + fuse.inode_store() + .update_remote_uri(_inode, &new_remote_uri); + } + } + + // 更新 DB mapping + let _ = self + .db + .update_mapping_remote_uri(&root_id, old_remote_uri, &new_remote_uri) + .await; + + // 抑制 SSE 回弹 + self.suppress_paths + .insert(old_relative_path.to_string(), std::time::Instant::now()); + self.suppress_paths + .insert(new_relative_path.to_string(), std::time::Instant::now()); + + tracing::info!( + "FUSE 重命名/移动成功: {} → {}", + old_relative_path, + new_relative_path + ); + let _ = reply_tx.send(Ok(())); + } + Err(e) => { + tracing::error!("FUSE 重命名/移动失败: {}: {}", old_name, e); + let _ = reply_tx.send(Err(format!("重命名/移动失败: {}", e))); + } + } + } + + /// MirrorFUSE: 为远程文件注册 FUSE inode(持续同步时远程新建/修改文件调用) + pub(crate) async fn _create_placeholder_for_remote( + &self, + relative: &str, + remote: &RemoteFileEntry, + _local_root: &std::path::Path, + _root_id: &str, + ) { + let adapter = match self.fuse_adapter.lock() { + Ok(guard) => guard, + Err(e) => { + tracing::error!("FUSE adapter lock 失败: {}", e); + return; + } + }; + if let Some(ref fuse) = *adapter { + let parent_rel = std::path::PathBuf::from(relative) + .parent() + .map(|p| crate::utils::normalize_path(&p.to_string_lossy())) + .unwrap_or_default(); + + let name = std::path::PathBuf::from(relative) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + fuse.create_placeholder_for_remote( + &parent_rel, + &name, + relative, + remote.is_dir, + remote.size, + &remote.uri, + remote.hash.as_deref(), + remote.mtime_ms, + ); + } + } + + /// FUSE 清理(卸载挂载点) + pub(crate) fn cleanup_fuse(&self) { + let adapter_opt = match self.fuse_adapter.lock() { + Ok(mut guard) => guard.take(), + Err(e) => { + tracing::error!("FUSE adapter lock 失败: {}", e); + return; + } + }; + if let Some(adapter) = adapter_opt { + if let Err(e) = adapter.unmount() { + tracing::warn!("FUSE 卸载失败: {}", e); + } + tracing::info!("FUSE 适配器已清理"); + } + } +} diff --git a/native/sync-core/src/sync_engine/initial_sync.rs b/native/sync-core/src/sync_engine/initial_sync.rs index 08bdcaf..5521bc5 100644 --- a/native/sync-core/src/sync_engine/initial_sync.rs +++ b/native/sync-core/src/sync_engine/initial_sync.rs @@ -23,7 +23,11 @@ impl SyncEngine { let (local_root, remote_root, sync_mode) = { let config = self.config.read().await; - (config.local_root.clone(), config.remote_root.clone(), config.sync_mode.clone()) + ( + config.local_root.clone(), + config.remote_root.clone(), + config.sync_mode.clone(), + ) }; tracing::info!("开始初始同步, 模式={:?}", sync_mode); @@ -47,7 +51,13 @@ impl SyncEngine { } let db_mappings = self.load_all_mappings().await?; - let plan = crate::diff::compute_diff(&local_files, &remote_files, &db_mappings, &remote_root, &sync_mode); + let plan = crate::diff::compute_diff( + &local_files, + &remote_files, + &db_mappings, + &remote_root, + &sync_mode, + ); tracing::info!( "差异计算完成: 上传={}, 下载={}, 删本地={}, 删远程={}, 冲突={}", plan.uploads.len(), @@ -83,7 +93,8 @@ impl SyncEngine { self.db.clone(), self.api.clone(), config.clone(), - ).map_err(|e| crate::errors::SyncError::Internal(e.to_string()))?; + ) + .map_err(|e| crate::errors::SyncError::Internal(e.to_string()))?; let fetch_rx = adapter.take_fetch_receiver(); *self.wcf_fetch_rx.lock().unwrap() = fetch_rx; let adapter_arc = std::sync::Arc::new(adapter); @@ -96,9 +107,79 @@ impl SyncEngine { } } - let result = self.worker_pool.submit( - plan, worker_config, WorkerTrigger::InitialSync, conflict_resolver, - ).await; + // MirrorFUSE 模式:初始化 FUSE 平台适配器(直接挂载到 local_root) + #[cfg(feature = "linux-fuse")] + if matches!(sync_mode, SyncMode::MirrorWcf) { + let already_initialized = self + .fuse_adapter + .lock() + .map(|g| g.is_some()) + .unwrap_or(false); + if !already_initialized { + let config = self.config.read().await; + let mount_path = config.local_root.clone(); + let adapter = crate::platform::fuse::FusePlatformAdapter::new( + &mount_path, + self.db.clone(), + self.api.clone(), + config.clone(), + ) + .map_err(|e| crate::errors::SyncError::Internal(e.to_string()))?; + + // 注册所有远程文件到 FUSE inode 表 + for remote in &remote_files { + let relative = crate::diff::remote_relative_path( + &remote_root, + &remote.path, + &remote.name, + remote.is_dir, + ); + let parent_rel = std::path::PathBuf::from(&relative) + .parent() + .map(|p| crate::utils::normalize_path(&p.to_string_lossy())) + .unwrap_or_default(); + let name = std::path::PathBuf::from(&relative) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + adapter.create_placeholder_for_remote( + &parent_rel, + &name, + &relative, + remote.is_dir, + remote.size, + &remote.uri, + remote.hash.as_deref(), + remote.mtime_ms, + ); + } + + let request_rx = adapter.take_request_receiver(); + if let Ok(mut rx) = self.fuse_request_rx.lock() { + *rx = request_rx; + } + if let Ok(mut adapter_guard) = self.fuse_adapter.lock() { + *adapter_guard = Some(std::sync::Arc::new(adapter)); + } + tracing::info!( + "MirrorFUSE: FUSE 平台适配器已初始化, 挂载点={}, inode 数={}", + mount_path.display(), + remote_files.len() + ); + } else { + tracing::info!("MirrorFUSE: FUSE 平台适配器已存在,跳过重复初始化"); + } + } + + let result = self + .worker_pool + .submit( + plan, + worker_config, + WorkerTrigger::InitialSync, + conflict_resolver, + ) + .await; match result { Ok(summary) => { diff --git a/native/sync-core/src/sync_engine/local_events.rs b/native/sync-core/src/sync_engine/local_events.rs index d822835..2a848fe 100644 --- a/native/sync-core/src/sync_engine/local_events.rs +++ b/native/sync-core/src/sync_engine/local_events.rs @@ -24,28 +24,60 @@ impl SyncEngine { // 清理过期的 suppress 记录(超过 30 秒) let now = std::time::Instant::now(); - self.suppress_paths.retain(|_, ts| now.duration_since(*ts).as_secs() < 30); + self.suppress_paths + .retain(|_, ts| now.duration_since(*ts).as_secs() < 30); // === 第一步:提取 Renamed/Moved 事件,查 DB 构建操作 === let mut rename_remote: Vec = Vec::new(); let mut move_remote: Vec = Vec::new(); - let mut handled_old_rels: std::collections::HashSet = std::collections::HashSet::new(); - let mut handled_new_rels: std::collections::HashSet = std::collections::HashSet::new(); + let mut handled_old_rels: std::collections::HashSet = + std::collections::HashSet::new(); + let mut handled_new_rels: std::collections::HashSet = + std::collections::HashSet::new(); for event in &all_events { match event { - LocalFileEvent::Renamed { old_paths, new_paths } => { + LocalFileEvent::Renamed { + old_paths, + new_paths, + } => { for (old_path, new_path) in old_paths.iter().zip(new_paths.iter()) { if let Some((old_rel, new_rel)) = rel_pair(local_root, old_path, new_path) { - if self.suppress_paths.contains_key(&old_rel) || self.suppress_paths.contains_key(&new_rel) { - tracing::trace!("本地重命名被抑制(远程操作导致): {} -> {}", old_rel, new_rel); + if self.suppress_paths.contains_key(&old_rel) + || self.suppress_paths.contains_key(&new_rel) + { + tracing::trace!( + "本地重命名被抑制(远程操作导致): {} -> {}", + old_rel, + new_rel + ); continue; } - let new_name = new_path.file_name() + let new_name = new_path + .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); - if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, &old_rel).await { + // Android 回收站:.trashed- 前缀视为本地删除,不重命名远程 + if new_name.starts_with(".trashed-") { + if let Ok(Some(_)) = + self.db.get_file_mapping(&root_id, &old_rel).await + { + tracing::info!( + "检测到本地回收站重命名,视为删除: {} -> {}", + old_rel, + new_rel + ); + let _ = self.db.delete_file_mapping(&root_id, &old_rel).await; + handled_old_rels.insert(old_rel); + handled_new_rels.insert(new_rel); + } + continue; + } + + if let Ok(Some(mapping)) = + self.db.get_file_mapping(&root_id, &old_rel).await + { tracing::info!("检测到本地重命名: {} -> {}", old_rel, new_rel); rename_remote.push(RenameAction { old_relative_path: old_rel.clone(), @@ -56,27 +88,67 @@ impl SyncEngine { handled_old_rels.insert(old_rel); handled_new_rels.insert(new_rel); } else { - tracing::info!("本地重命名但旧路径无DB映射,按新建处理: {} -> {}", old_rel, new_rel); + tracing::info!( + "本地重命名但旧路径无DB映射,按新建处理: {} -> {}", + old_rel, + new_rel + ); } } } } - LocalFileEvent::Moved { old_paths, new_paths } => { + LocalFileEvent::Moved { + old_paths, + new_paths, + } => { for (old_path, new_path) in old_paths.iter().zip(new_paths.iter()) { if let Some((old_rel, new_rel)) = rel_pair(local_root, old_path, new_path) { - if self.suppress_paths.contains_key(&old_rel) || self.suppress_paths.contains_key(&new_rel) { - tracing::trace!("本地移动被抑制(远程操作导致): {} -> {}", old_rel, new_rel); + if self.suppress_paths.contains_key(&old_rel) + || self.suppress_paths.contains_key(&new_rel) + { + tracing::trace!( + "本地移动被抑制(远程操作导致): {} -> {}", + old_rel, + new_rel + ); continue; } - if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, &old_rel).await { + let new_name = new_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Android 回收站:移动到 .trashed- 前缀文件,视为本地删除 + if new_name.starts_with(".trashed-") { + if let Ok(Some(_)) = + self.db.get_file_mapping(&root_id, &old_rel).await + { + tracing::info!( + "检测到本地回收站移动,视为删除: {} -> {}", + old_rel, + new_rel + ); + let _ = self.db.delete_file_mapping(&root_id, &old_rel).await; + handled_old_rels.insert(old_rel); + handled_new_rels.insert(new_rel); + } + continue; + } + + if let Ok(Some(mapping)) = + self.db.get_file_mapping(&root_id, &old_rel).await + { let remote_root = { self.config.read().await.remote_root.clone() }; let new_rel_path = std::path::PathBuf::from(&new_rel); - let dst_dir_rel = new_rel_path.parent() + let dst_dir_rel = new_rel_path + .parent() .map(|p| crate::utils::normalize_path(&p.to_string_lossy())) .unwrap_or_default(); - let dst_remote_dir_uri = format!("{}/{}", + let dst_remote_dir_uri = format!( + "{}/{}", remote_root.trim_end_matches('/'), - dst_dir_rel.trim_start_matches('/')); + dst_dir_rel.trim_start_matches('/') + ); tracing::info!("检测到本地移动: {} -> {}", old_rel, new_rel); move_remote.push(MoveAction { @@ -88,7 +160,11 @@ impl SyncEngine { handled_old_rels.insert(old_rel); handled_new_rels.insert(new_rel); } else { - tracing::info!("本地移动但旧路径无DB映射,按新建处理: {} -> {}", old_rel, new_rel); + tracing::info!( + "本地移动但旧路径无DB映射,按新建处理: {} -> {}", + old_rel, + new_rel + ); } } } @@ -98,8 +174,10 @@ impl SyncEngine { } // === 第二步:按事件类型分类路径,跳过已识别为 rename/move 的路径 === - let mut create_paths: std::collections::BTreeMap = std::collections::BTreeMap::new(); - let mut delete_paths: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut create_paths: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + let mut delete_paths: std::collections::BTreeSet = + std::collections::BTreeSet::new(); for event in &all_events { for path in event.paths() { @@ -107,21 +185,31 @@ impl SyncEngine { continue; } - let file_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - if crate::fs_scanner::SKIP_NAMES.iter().any(|s| file_name == *s) + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if crate::fs_scanner::SKIP_NAMES + .iter() + .any(|s| file_name == *s) || file_name.starts_with(".sync_") - || crate::utils::is_conflict_file(&file_name) { + || file_name.starts_with(".trashed-") + || crate::utils::is_conflict_file(&file_name) + { continue; } - let relative = path.strip_prefix(local_root) + let relative = path + .strip_prefix(local_root) .unwrap_or(path) .to_string_lossy() .to_string(); let relative = crate::utils::normalize_path(&relative); - if handled_old_rels.contains(&relative) || handled_new_rels.contains(&relative) - || self.suppress_paths.contains_key(&relative) { + if handled_old_rels.contains(&relative) + || handled_new_rels.contains(&relative) + || self.suppress_paths.contains_key(&relative) + { continue; } @@ -145,33 +233,54 @@ impl SyncEngine { // === hash 匹配回退:检测 delete+create 为 rename 的情况 === // MirrorWcf: 跳过 hash 匹配回退,因为读取占位符文件会被 CFApi 拦截导致 426 超时 - if !delete_paths.is_empty() && !create_paths.is_empty() && !matches!(sync_mode, SyncMode::MirrorWcf) { - let mut matched_deletes: std::collections::HashSet = std::collections::HashSet::new(); - let mut matched_creates: std::collections::HashSet = std::collections::HashSet::new(); + if !delete_paths.is_empty() + && !create_paths.is_empty() + && !matches!(sync_mode, SyncMode::MirrorWcf) + { + let mut matched_deletes: std::collections::HashSet = + std::collections::HashSet::new(); + let mut matched_creates: std::collections::HashSet = + std::collections::HashSet::new(); for (new_rel, new_path) in &create_paths { if let Ok(metadata) = tokio::fs::metadata(new_path).await { - if metadata.is_dir() || metadata.len() == 0 { continue; } - let new_hash = crate::utils::quick_hash(new_path, metadata.len()).await.unwrap_or_default(); - if new_hash.is_empty() { continue; } + if metadata.is_dir() || metadata.len() == 0 { + continue; + } + let new_hash = crate::utils::quick_hash(new_path, metadata.len()) + .await + .unwrap_or_default(); + if new_hash.is_empty() { + continue; + } for del_rel in &delete_paths { - if matched_deletes.contains(del_rel.as_str()) { continue; } - if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, del_rel).await { + if matched_deletes.contains(del_rel.as_str()) { + continue; + } + if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, del_rel).await + { if mapping.local_hash.as_deref() == Some(&new_hash) { - let new_name = new_path.file_name() + let new_name = new_path + .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); - let old_dir = std::path::PathBuf::from(del_rel).parent() + let old_dir = std::path::PathBuf::from(del_rel) + .parent() .map(|p| crate::utils::normalize_path(&p.to_string_lossy())) .unwrap_or_default(); - let new_dir = std::path::PathBuf::from(new_rel.as_str()).parent() + let new_dir = std::path::PathBuf::from(new_rel.as_str()) + .parent() .map(|p| crate::utils::normalize_path(&p.to_string_lossy())) .unwrap_or_default(); if old_dir == new_dir { - tracing::info!("hash匹配检测到重命名: {} -> {}", del_rel, new_rel); + tracing::info!( + "hash匹配检测到重命名: {} -> {}", + del_rel, + new_rel + ); rename_remote.push(RenameAction { old_relative_path: del_rel.clone(), new_relative_path: new_rel.clone(), @@ -179,11 +288,18 @@ impl SyncEngine { new_name, }); } else { - let remote_root = { self.config.read().await.remote_root.clone() }; - let dst_remote_dir_uri = format!("{}/{}", + let remote_root = + { self.config.read().await.remote_root.clone() }; + let dst_remote_dir_uri = format!( + "{}/{}", remote_root.trim_end_matches('/'), - new_dir.trim_start_matches('/')); - tracing::info!("hash匹配检测到移动: {} -> {}", del_rel, new_rel); + new_dir.trim_start_matches('/') + ); + tracing::info!( + "hash匹配检测到移动: {} -> {}", + del_rel, + new_rel + ); move_remote.push(MoveAction { old_relative_path: del_rel.clone(), new_relative_path: new_rel.clone(), @@ -212,9 +328,14 @@ impl SyncEngine { }; let worker_config = self.snapshot_worker_config().await; let conflict_resolver = self.conflict.read().await.clone(); - self.worker_pool.submit_background( - plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, - ).await; + self.worker_pool + .submit_background( + plan, + worker_config, + WorkerTrigger::Continuous, + conflict_resolver, + ) + .await; } // === 提交移动任务 === @@ -225,9 +346,14 @@ impl SyncEngine { }; let worker_config = self.snapshot_worker_config().await; let conflict_resolver = self.conflict.read().await.clone(); - self.worker_pool.submit_background( - plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, - ).await; + self.worker_pool + .submit_background( + plan, + worker_config, + WorkerTrigger::Continuous, + conflict_resolver, + ) + .await; } // === 提交上传任务 (Create/Modify) === @@ -265,12 +391,21 @@ impl SyncEngine { let quick_hash = if matches!(sync_mode, SyncMode::MirrorWcf) { String::new() } else { - crate::utils::quick_hash(path, size).await.unwrap_or_default() + crate::utils::quick_hash(path, size) + .await + .unwrap_or_default() }; - let db_mapping = self.db.get_file_mapping(&root_id, relative).await.ok().flatten(); + let db_mapping = self + .db + .get_file_mapping(&root_id, relative) + .await + .ok() + .flatten(); if let Some(ref mapping) = db_mapping { - if !quick_hash.is_empty() && mapping.local_hash.as_deref() == Some(&quick_hash) { + if !quick_hash.is_empty() + && mapping.local_hash.as_deref() == Some(&quick_hash) + { continue; } if mapping.is_placeholder { @@ -278,7 +413,8 @@ impl SyncEngine { } } - let mtime_ms = metadata.modified() + let mtime_ms = metadata + .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_millis() as i64) @@ -303,28 +439,33 @@ impl SyncEngine { let scan_dirs = find_top_level_dirs(&dir_paths); - let all_handled: std::collections::HashSet<&String> = handled_old_rels.iter() + let all_handled: std::collections::HashSet<&String> = handled_old_rels + .iter() .chain(handled_new_rels.iter()) .collect(); - let filtered_scan_dirs: Vec = scan_dirs.into_iter().filter(|dir| { - if all_handled.iter().any(|rel| { - rel.starts_with(dir.as_str()) - && rel.as_bytes().get(dir.len()) == Some(&b'/') - }) { - return false; - } - for entry in self.suppress_paths.iter() { - let rel = entry.key(); - if rel.starts_with(dir.as_str()) - && rel.as_bytes().get(dir.len()) == Some(&b'/') { + let filtered_scan_dirs: Vec = scan_dirs + .into_iter() + .filter(|dir| { + if all_handled.iter().any(|rel| { + rel.starts_with(dir.as_str()) + && rel.as_bytes().get(dir.len()) == Some(&b'/') + }) { return false; } - if dir.as_str() == rel.as_str() { - return false; + for entry in self.suppress_paths.iter() { + let rel = entry.key(); + if rel.starts_with(dir.as_str()) + && rel.as_bytes().get(dir.len()) == Some(&b'/') + { + return false; + } + if dir.as_str() == rel.as_str() { + return false; + } } - } - true - }).collect(); + true + }) + .collect(); if !filtered_scan_dirs.is_empty() { uploads.retain(|action| { @@ -338,8 +479,10 @@ impl SyncEngine { if !uploads.is_empty() || !filtered_scan_dirs.is_empty() { tracing::info!( "本地事件收集完成: 上传={}, 目录扫描={:?}, 跳过(未稳定)={}, 跳过(重复上传)={}", - uploads.len(), filtered_scan_dirs, - skipped_unstable, skipped_uploading, + uploads.len(), + filtered_scan_dirs, + skipped_unstable, + skipped_uploading, ); let plan = SyncPlan { uploads, @@ -348,9 +491,14 @@ impl SyncEngine { }; let worker_config = self.snapshot_worker_config().await; let conflict_resolver = self.conflict.read().await.clone(); - self.worker_pool.submit_background( - plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, - ).await; + self.worker_pool + .submit_background( + plan, + worker_config, + WorkerTrigger::Continuous, + conflict_resolver, + ) + .await; } } @@ -359,7 +507,9 @@ impl SyncEngine { // MirrorWcf 模式:仅在 wcf_delete_mode == SyncRemote 时删除远程,否则仅删除本地(保留远程以便重新水合) let wcf_should_delete_remote = matches!(sync_mode, SyncMode::MirrorWcf) && matches!(wcf_delete_mode, WcfDeleteMode::SyncRemote); - if !delete_paths.is_empty() && (matches!(sync_mode, SyncMode::Full) || wcf_should_delete_remote) { + if !delete_paths.is_empty() + && (matches!(sync_mode, SyncMode::Full) || wcf_should_delete_remote) + { let mut delete_remote: Vec = Vec::new(); for relative in &delete_paths { tracing::info!("检测到本地文件删除: {}", relative); @@ -392,9 +542,14 @@ impl SyncEngine { }; let worker_config = self.snapshot_worker_config().await; let conflict_resolver = self.conflict.read().await.clone(); - self.worker_pool.submit_background( - plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, - ).await; + self.worker_pool + .submit_background( + plan, + worker_config, + WorkerTrigger::Continuous, + conflict_resolver, + ) + .await; } } @@ -405,20 +560,47 @@ impl SyncEngine { tracing::debug!("仅上传模式: 本地删除仅清理映射,不删除远程: {}", relative); } } + + // MirrorWcf DeleteLocalOnly 模式下:本地删除仅清理 DB 映射,保留远程可重新水合 + let wcf_delete_local_only = matches!(sync_mode, SyncMode::MirrorWcf) + && !matches!(wcf_delete_mode, WcfDeleteMode::SyncRemote); + if !delete_paths.is_empty() && wcf_delete_local_only { + for relative in &delete_paths { + if let Ok(Some(_)) = self.db.get_file_mapping(&root_id, relative).await { + let _ = self.db.delete_file_mapping(&root_id, relative).await; + tracing::info!("MirrorWcf(仅删除本地): 清理映射,保留远程: {}", relative); + } + } + } } } /// 从两个绝对路径生成相对于 local_root 的相对路径对 -fn rel_pair(local_root: &std::path::Path, old_path: &std::path::Path, new_path: &std::path::Path) -> Option<(String, String)> { - let old_rel = old_path.strip_prefix(local_root).ok()? - .to_string_lossy().to_string(); - let new_rel = new_path.strip_prefix(local_root).ok()? - .to_string_lossy().to_string(); - Some((crate::utils::normalize_path(&old_rel), crate::utils::normalize_path(&new_rel))) +fn rel_pair( + local_root: &std::path::Path, + old_path: &std::path::Path, + new_path: &std::path::Path, +) -> Option<(String, String)> { + let old_rel = old_path + .strip_prefix(local_root) + .ok()? + .to_string_lossy() + .to_string(); + let new_rel = new_path + .strip_prefix(local_root) + .ok()? + .to_string_lossy() + .to_string(); + Some(( + crate::utils::normalize_path(&old_rel), + crate::utils::normalize_path(&new_rel), + )) } fn find_top_level_dirs(dirs: &[String]) -> Vec { - if dirs.is_empty() { return Vec::new(); } + if dirs.is_empty() { + return Vec::new(); + } let mut sorted: Vec<&String> = dirs.iter().collect(); sorted.sort(); @@ -426,8 +608,7 @@ fn find_top_level_dirs(dirs: &[String]) -> Vec { let mut top_level = Vec::new(); for dir in &sorted { let dominated = top_level.iter().any(|parent: &String| { - dir.starts_with(parent.as_str()) - && dir.as_bytes().get(parent.len()) == Some(&b'/') + dir.starts_with(parent.as_str()) && dir.as_bytes().get(parent.len()) == Some(&b'/') }); if !dominated { top_level.retain(|existing: &String| { @@ -449,13 +630,11 @@ async fn is_file_stable(path: &Path) -> bool { }; let path_display = path.display().to_string(); - let can_open = tokio::task::spawn_blocking(move || { - match std::fs::File::open(&path_display) { - Ok(_) => true, - Err(e) => { - tracing::trace!("文件稳定性检测打开失败: {}", e); - false - } + let can_open = tokio::task::spawn_blocking(move || match std::fs::File::open(&path_display) { + Ok(_) => true, + Err(e) => { + tracing::trace!("文件稳定性检测打开失败: {}", e); + false } }) .await diff --git a/native/sync-core/src/sync_engine/mod.rs b/native/sync-core/src/sync_engine/mod.rs index efcba33..999c4f7 100644 --- a/native/sync-core/src/sync_engine/mod.rs +++ b/native/sync-core/src/sync_engine/mod.rs @@ -1,13 +1,15 @@ -mod initial_sync; +mod album; mod continuous_sync; +#[cfg(feature = "linux-fuse")] +mod fuse; +mod initial_sync; mod local_events; mod remote_events; -mod album; #[cfg(feature = "windows-cfapi")] mod wcf; -// 非 WCF feature 下的 stub 方法,供 remote_events.rs 编译通过 -#[cfg(not(feature = "windows-cfapi"))] +// 非 WCF/FUSE feature 下的 stub 方法,供 remote_events.rs 编译通过 +#[cfg(not(any(feature = "windows-cfapi", feature = "linux-fuse")))] impl SyncEngine { async fn _create_placeholder_for_remote( &self, @@ -16,7 +18,7 @@ impl SyncEngine { _local_root: &std::path::Path, _root_id: &str, ) { - // MirrorWcf 模式在非 Windows 平台不可用,此方法不应被调用 + // MirrorWcf 模式在非 Windows/Linux-FUSE 平台不可用,此方法不应被调用 } } @@ -31,9 +33,9 @@ use crate::worker::WorkerPool; use dashmap::DashMap; use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::RwLock; #[cfg(feature = "windows-cfapi")] use tokio::sync::mpsc; +use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; pub struct SyncEngine { @@ -66,15 +68,35 @@ pub struct SyncEngine { /// 缓存的本地同步根路径(WCF 清理时同步读取,避免 await) #[cfg(feature = "windows-cfapi")] cached_local_root: std::sync::Mutex, + /// FUSE 平台适配器(仅 MirrorWcf + linux-fuse 模式下初始化) + #[cfg(feature = "linux-fuse")] + fuse_adapter: std::sync::Mutex>>, + /// FUSE 请求接收端(在适配器初始化时提取) + #[cfg(feature = "linux-fuse")] + fuse_request_rx: + std::sync::Mutex>>, + /// FUSE 水合缓存:uri → 已下载的完整文件数据 + #[cfg(feature = "linux-fuse")] + hydration_cache: Arc, std::time::Instant)>>, } impl SyncEngine { pub async fn new(config: SyncConfig) -> Result { - let db_path = config.data_dir.join("sync_core").join("datas").join(".sync_db.sqlite3"); + let db_path = config + .data_dir + .join("sync_core") + .join("datas") + .join(".sync_db.sqlite3"); let db_path_clone = db_path.clone(); - let db = Arc::new(tokio::task::spawn_blocking(move || SyncDb::open(&db_path_clone)).await??); + let db = + Arc::new(tokio::task::spawn_blocking(move || SyncDb::open(&db_path_clone)).await??); - let api = Arc::new(ApiClient::new(&config.base_url, &config.access_token, &config.refresh_token, &config.client_id)); + let api = Arc::new(ApiClient::new( + &config.base_url, + &config.access_token, + &config.refresh_token, + &config.client_id, + )); let conflict = ConflictResolver::new(config.conflict_strategy.clone()); @@ -127,6 +149,12 @@ impl SyncEngine { hydration_cache: Arc::new(DashMap::new()), #[cfg(feature = "windows-cfapi")] cached_local_root: std::sync::Mutex::new(std::path::PathBuf::new()), + #[cfg(feature = "linux-fuse")] + fuse_adapter: std::sync::Mutex::new(None), + #[cfg(feature = "linux-fuse")] + fuse_request_rx: std::sync::Mutex::new(None), + #[cfg(feature = "linux-fuse")] + hydration_cache: Arc::new(DashMap::new()), }) } @@ -184,8 +212,8 @@ impl SyncEngine { } /// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 - pub async fn reset_sync(&self) -> Result<()> { - tracing::info!("开始重置同步..."); + pub async fn reset_sync(&self, delete_local_files: bool) -> Result<()> { + tracing::info!("开始重置同步... delete_local_files={}", delete_local_files); // 1. 停止同步 self.stop().await?; @@ -196,6 +224,12 @@ impl SyncEngine { self.cleanup_wcf(); } + // 2b. 清理 FUSE + #[cfg(feature = "linux-fuse")] + { + self.cleanup_fuse(); + } + // 3. 终止所有活跃 Worker // 2. 终止所有活跃 Worker @@ -206,19 +240,27 @@ impl SyncEngine { tracing::info!("同步数据库已清空"); // 4. 清空本地同步目录(保留目录本身,只删内容) - let local_root = self.config.read().await.local_root.clone(); - if local_root.exists() { - let entries = std::fs::read_dir(&local_root) - .map_err(|_| crate::errors::SyncError::DiskFull { needed: 0, available: 0 })?; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let _ = std::fs::remove_dir_all(&path); - } else { - let _ = std::fs::remove_file(&path); + if delete_local_files { + let local_root = self.config.read().await.local_root.clone(); + if local_root.exists() { + let entries = std::fs::read_dir(&local_root).map_err(|_| { + crate::errors::SyncError::DiskFull { + needed: 0, + available: 0, + } + })?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let _ = std::fs::remove_dir_all(&path); + } else { + let _ = std::fs::remove_file(&path); + } } + tracing::info!("本地同步目录已清空: {}", local_root.display()); } - tracing::info!("本地同步目录已清空: {}", local_root.display()); + } else { + tracing::info!("跳过清空本地同步目录"); } // 5. 清空内存缓存 @@ -232,14 +274,30 @@ impl SyncEngine { Ok(()) } - pub fn status(&self) -> SyncStatusSnapshot { - let state = self.state.try_read().map(|g| g.clone()).unwrap_or(SyncState::Idle); + pub async fn status(&self) -> SyncStatusSnapshot { + let state = self + .state + .try_read() + .map(|g| g.clone()) + .unwrap_or(SyncState::Idle); let (synced_files, total_files) = match &state { SyncState::InitialSync { progress } => { let done = progress.uploaded + progress.downloaded; (done, progress.total_to_sync) } + SyncState::Continuous | SyncState::Paused => { + // 持续同步/暂停:从活跃任务聚合进度 + let mut synced: u64 = 0; + let mut total: u64 = 0; + if let Ok(tasks) = self.db.get_active_sync_tasks().await { + for t in &tasks { + synced += t.completed_count as u64; + total += t.total_count as u64; + } + } + (synced, total) + } _ => (0, 0), }; @@ -288,7 +346,11 @@ impl SyncEngine { } tracing::info!( "同步配置已更新: 模式={}, 冲突策略={}, WCF删除={}, 并发={}, 带宽限制={:?}", - new_mode, new_conflict, new_wcf_delete, new_max_concurrent, new_bandwidth + new_mode, + new_conflict, + new_wcf_delete, + new_max_concurrent, + new_bandwidth ); Ok(()) } @@ -297,7 +359,10 @@ impl SyncEngine { self.api.update_token(token).await; } - pub async fn register_event_sink(&self, sink: crate::frb_generated::StreamSink) { + pub async fn register_event_sink( + &self, + sink: crate::frb_generated::StreamSink, + ) { self.event_sink.register(sink).await; } @@ -317,6 +382,10 @@ impl SyncEngine { self.db.query_task_items(filter).await } + pub async fn get_cum_stats(&self) -> Result { + self.db.get_cum_stats().await + } + pub async fn hydrate_file(&self, local_path: &str) -> Result<()> { #[cfg(feature = "windows-cfapi")] { @@ -340,42 +409,46 @@ impl SyncEngine { }; let pool = self.db.read_pool(); - let result = tokio::task::spawn_blocking(move || -> Result> { - let conn = pool.get()?; - let mut stmt = conn.prepare( - "SELECT id, sync_root_id, local_path, remote_uri, remote_file_id, + let result = + tokio::task::spawn_blocking(move || -> Result> { + let conn = pool.get()?; + let mut stmt = conn.prepare( + "SELECT id, sync_root_id, local_path, remote_uri, remote_file_id, local_hash, remote_hash, local_mtime, remote_mtime, local_size, remote_size, sync_status, is_placeholder - FROM file_mapping WHERE sync_root_id = ?1" - )?; + FROM file_mapping WHERE sync_root_id = ?1", + )?; - let mappings: HashMap = stmt.query_map( - rusqlite::params![root_id], - |row| { - let local_path: String = row.get(2)?; - Ok(( - crate::utils::normalize_path(&local_path), - FileMapping { - id: row.get(0)?, - sync_root_id: row.get(1)?, - local_path: std::path::PathBuf::from(local_path), - remote_uri: row.get(3)?, - remote_file_id: row.get(4)?, - local_hash: row.get(5)?, - remote_hash: row.get(6)?, - local_mtime: row.get(7)?, - remote_mtime: row.get(8)?, - local_size: row.get(9)?, - remote_size: row.get(10)?, - sync_status: crate::diff::parse_sync_status_from_str(&row.get::<_, String>(11)?), - is_placeholder: row.get::<_, i32>(12)? != 0, - }, - )) - }, - )?.filter_map(|r| r.ok()).collect(); + let mappings: HashMap = stmt + .query_map(rusqlite::params![root_id], |row| { + let local_path: String = row.get(2)?; + Ok(( + crate::utils::normalize_path(&local_path), + FileMapping { + id: row.get(0)?, + sync_root_id: row.get(1)?, + local_path: std::path::PathBuf::from(local_path), + remote_uri: row.get(3)?, + remote_file_id: row.get(4)?, + local_hash: row.get(5)?, + remote_hash: row.get(6)?, + local_mtime: row.get(7)?, + remote_mtime: row.get(8)?, + local_size: row.get(9)?, + remote_size: row.get(10)?, + sync_status: crate::diff::parse_sync_status_from_str( + &row.get::<_, String>(11)?, + ), + is_placeholder: row.get::<_, i32>(12)? != 0, + }, + )) + })? + .filter_map(|r| r.ok()) + .collect(); - Ok(mappings) - }).await??; + Ok(mappings) + }) + .await??; Ok(result) } diff --git a/native/sync-core/src/sync_engine/remote_events.rs b/native/sync-core/src/sync_engine/remote_events.rs index bd3a06a..1e8552a 100644 --- a/native/sync-core/src/sync_engine/remote_events.rs +++ b/native/sync-core/src/sync_engine/remote_events.rs @@ -11,6 +11,7 @@ impl SyncEngine { remote_root: &str, ) { // UploadOnly: 忽略所有远程事件 + // AlbumDownload: 忽略删除事件(不删本地照片) let sync_mode = { let config = self.config.read().await; config.sync_mode.clone() @@ -30,12 +31,21 @@ impl SyncEngine { &remote.name, remote.is_dir, ); - tracing::info!("[远程事件] {}/{:?}: {}", event_type_name(&event), remote.file_id, relative); + tracing::info!( + "[远程事件] {}/{:?}: {}", + event_type_name(&event), + remote.file_id, + relative + ); let remote_entry = if remote.size == 0 && !remote.is_dir { match self.api.get_file_info(&remote.uri).await { Ok(info) => { - tracing::debug!("[远程事件] 获取文件详情成功: {} ({}bytes)", relative, info.size); + tracing::debug!( + "[远程事件] 获取文件详情成功: {} ({}bytes)", + relative, + info.size + ); info } Err(e) => { @@ -58,8 +68,19 @@ impl SyncEngine { if is_mirror_wcf { self._create_placeholder_for_remote( - &relative, &remote_entry, local_root, &root_id, - ).await; + &relative, + &remote_entry, + local_root, + &root_id, + ) + .await; + self._record_wcf_stats( + &relative, + TaskActionType::CreatePlaceholder, + remote_entry.size, + None, + ) + .await; } else { let plan = SyncPlan { downloads: vec![SyncAction { @@ -72,24 +93,32 @@ impl SyncEngine { }; let worker_config = self.snapshot_worker_config().await; let conflict_resolver = self.conflict.read().await.clone(); - self.worker_pool.submit_background( - plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, - ).await; + self.worker_pool + .submit_background( + plan, + worker_config, + WorkerTrigger::Continuous, + conflict_resolver, + ) + .await; } } RemoteFileEvent::Deleted { uri, name } => { // 清理过期抑制条目 let now = std::time::Instant::now(); - self.suppress_paths.retain(|_, ts| now.duration_since(*ts).as_secs() < 30); + self.suppress_paths + .retain(|_, ts| now.duration_since(*ts).as_secs() < 30); - let relative = crate::diff::remote_relative_path( - remote_root, - uri, - name, - false, - ); + let relative = crate::diff::remote_relative_path(remote_root, uri, name, false); tracing::info!("[远程事件] 删除: {}", relative); + // AlbumDownload: 远程删除不删除本地文件,仅清理 DB 映射 + if matches!(sync_mode, SyncMode::AlbumDownload) { + tracing::info!("[远程事件] AlbumDownload 模式,跳过本地删除: {}", relative); + let _ = self.db.delete_file_mapping(&root_id, &relative).await; + return; + } + // 被抑制的路径:上传失败清理远端碎片等场景,不应删除本地文件 if self.suppress_paths.contains_key(&relative) { tracing::info!("[远程事件] 删除已抑制,跳过本地删除: {}", relative); @@ -98,7 +127,8 @@ impl SyncEngine { } let local_path = local_root.join(&relative); - if local_path.exists() { + let existed = local_path.exists(); + if existed { if local_path.is_dir() { let _ = tokio::fs::remove_dir_all(&local_path).await; } else { @@ -107,15 +137,27 @@ impl SyncEngine { tracing::info!("[远程事件] 已删除本地文件: {}", relative); } let _ = self.db.delete_file_mapping(&root_id, &relative).await; - self.suppress_paths.insert(relative.clone(), std::time::Instant::now()); + self.suppress_paths + .insert(relative.clone(), std::time::Instant::now()); + + // MirrorFUSE: 远程删除 → 从 FUSE inode 缓存移除 + #[cfg(feature = "linux-fuse")] + if is_mirror_wcf { + let adapter = self.fuse_adapter.lock().unwrap(); + if let Some(ref fuse) = *adapter { + fuse.remove_inode(&relative); + } + } + + // MirrorWcf: 远程删除 → 删本地,记录统计 + if is_mirror_wcf && existed { + self._record_wcf_stats(&relative, TaskActionType::DeleteLocal, 0, None) + .await; + } } RemoteFileEvent::Renamed { old_uri, new_entry } => { - let old_relative = crate::diff::remote_relative_path( - remote_root, - old_uri, - &new_entry.name, - false, - ); + let old_relative = + crate::diff::remote_relative_path(remote_root, old_uri, &new_entry.name, false); let new_relative = crate::diff::remote_relative_path( remote_root, &new_entry.path, @@ -141,14 +183,30 @@ impl SyncEngine { }; let worker_config = self.snapshot_worker_config().await; let conflict_resolver = self.conflict.read().await.clone(); - self.worker_pool.submit_background( - plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, - ).await; + self.worker_pool + .submit_background( + plan, + worker_config, + WorkerTrigger::Continuous, + conflict_resolver, + ) + .await; } else if is_mirror_wcf { let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; self._create_placeholder_for_remote( - &new_relative, &remote_entry, local_root, &root_id, - ).await; + &new_relative, + &remote_entry, + local_root, + &root_id, + ) + .await; + self._record_wcf_stats( + &format!("{} -> {}", old_relative, new_relative), + TaskActionType::Rename, + 0, + None, + ) + .await; } else { let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; let plan = SyncPlan { @@ -162,18 +220,19 @@ impl SyncEngine { }; let worker_config = self.snapshot_worker_config().await; let conflict_resolver = self.conflict.read().await.clone(); - self.worker_pool.submit_background( - plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, - ).await; + self.worker_pool + .submit_background( + plan, + worker_config, + WorkerTrigger::Continuous, + conflict_resolver, + ) + .await; } } RemoteFileEvent::Moved { old_uri, new_entry } => { - let old_relative = crate::diff::remote_relative_path( - remote_root, - old_uri, - &new_entry.name, - false, - ); + let old_relative = + crate::diff::remote_relative_path(remote_root, old_uri, &new_entry.name, false); let new_relative = crate::diff::remote_relative_path( remote_root, &new_entry.path, @@ -199,14 +258,30 @@ impl SyncEngine { }; let worker_config = self.snapshot_worker_config().await; let conflict_resolver = self.conflict.read().await.clone(); - self.worker_pool.submit_background( - plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, - ).await; + self.worker_pool + .submit_background( + plan, + worker_config, + WorkerTrigger::Continuous, + conflict_resolver, + ) + .await; } else if is_mirror_wcf { let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; self._create_placeholder_for_remote( - &new_relative, &remote_entry, local_root, &root_id, - ).await; + &new_relative, + &remote_entry, + local_root, + &root_id, + ) + .await; + self._record_wcf_stats( + &format!("{} -> {}", old_relative, new_relative), + TaskActionType::Move, + 0, + None, + ) + .await; } else { let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; let plan = SyncPlan { @@ -220,16 +295,61 @@ impl SyncEngine { }; let worker_config = self.snapshot_worker_config().await; let conflict_resolver = self.conflict.read().await.clone(); - self.worker_pool.submit_background( - plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, - ).await; + self.worker_pool + .submit_background( + plan, + worker_config, + WorkerTrigger::Continuous, + conflict_resolver, + ) + .await; } } } } + /// MirrorWcf 专用:记录绕过 WorkerPool 的操作统计 + async fn _record_wcf_stats( + &self, + relative_path: &str, + action_type: TaskActionType, + file_size: u64, + error_message: Option, + ) { + let now = chrono::Utc::now().to_rfc3339(); + let status = if error_message.is_none() { + TaskItemStatus::Completed + } else { + TaskItemStatus::Failed + }; + let task_id = format!("wcf_{}", uuid::Uuid::new_v4()); + if let Err(e) = self + .db + .record_standalone_task_item( + &WorkerTrigger::WcfEvent, + &SyncTaskItem { + id: 0, + task_id, + relative_path: relative_path.to_string(), + action_type, + status, + file_size, + error_message, + created_at: now.clone(), + updated_at: now, + }, + ) + .await + { + tracing::warn!("WCF 统计记录失败: {}", e); + } + } + /// 获取远程文件详情,失败则使用 SSE 数据回退 - pub(crate) async fn get_remote_entry_or_fallback(&self, entry: &RemoteFileEntry) -> RemoteFileEntry { + pub(crate) async fn get_remote_entry_or_fallback( + &self, + entry: &RemoteFileEntry, + ) -> RemoteFileEntry { if entry.size == 0 && !entry.is_dir { match self.api.get_file_info(&entry.uri).await { Ok(info) => info, diff --git a/native/sync-core/src/sync_engine/wcf.rs b/native/sync-core/src/sync_engine/wcf.rs index 7353656..56e830a 100644 --- a/native/sync-core/src/sync_engine/wcf.rs +++ b/native/sync-core/src/sync_engine/wcf.rs @@ -18,7 +18,8 @@ impl SyncEngine { Err(e) => { tracing::error!("WCF 水合: FileIdentity 反序列化失败: {}", e); let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data( - request.connection_key, request.transfer_key, + request.connection_key, + request.transfer_key, ); return; } @@ -31,19 +32,26 @@ impl SyncEngine { if remote_uri.is_empty() { tracing::error!("WCF 水合: FileIdentity 中 uri 为空"); let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data( - request.connection_key, request.transfer_key, + request.connection_key, + request.transfer_key, ); return; } - tracing::debug!("WCF 水合请求: uri={}, size={}, offset={}, length={}", - remote_uri, remote_size, request.required_offset, request.required_length); + tracing::debug!( + "WCF 水合请求: uri={}, size={}, offset={}, length={}", + remote_uri, + remote_size, + request.required_offset, + request.required_length + ); let root_id = match &self.sync_root_id { Some(id) => id.clone(), None => { let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data( - request.connection_key, request.transfer_key, + request.connection_key, + request.transfer_key, ); return; } @@ -51,12 +59,13 @@ impl SyncEngine { // 清理过期缓存(超过 5 分钟) let now = std::time::Instant::now(); - self.hydration_cache.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < 300); + self.hydration_cache + .retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < 300); - // 尝试从缓存获取已下载的数据 - let data = if let Some(cached) = self.hydration_cache.get(&remote_uri) { + // 尝试从缓存获取已下载的数据;cache miss 时下载并标记 is_new_download + let (data, is_new_download) = if let Some(cached) = self.hydration_cache.get(&remote_uri) { tracing::debug!("WCF 水合缓存命中: {}", remote_uri); - cached.0.clone() + (cached.0.clone(), false) } else { tracing::info!("WCF 水合下载: {} ({}bytes)", remote_uri, remote_size); let config = self.snapshot_worker_config().await; @@ -72,27 +81,57 @@ impl SyncEngine { } Err(e) => return Err(e), }; - let download_url = urls.into_iter().next() - .ok_or_else(|| crate::errors::SyncError::Network("获取下载 URL 返回空列表".into()))?; + let download_url = urls.into_iter().next().ok_or_else(|| { + crate::errors::SyncError::Network("获取下载 URL 返回空列表".into()) + })?; let data = crate::downloader::download_to_buffer( &self.api, &download_url, config.bandwidth_limit, - ).await?; + ) + .await?; Ok::, crate::errors::SyncError>(data) - }.await; + } + .await; match download_result { Ok(data) => { - self.hydration_cache.insert(remote_uri.clone(), (data.clone(), std::time::Instant::now())); - data + self.hydration_cache.insert( + remote_uri.clone(), + (data.clone(), std::time::Instant::now()), + ); + (data, true) } Err(e) => { tracing::error!("WCF 水合下载失败: {}: {}", remote_uri, e); + // 下载失败时记录一次统计 + let now_str = chrono::Utc::now().to_rfc3339(); + let task_id = format!("hydration_{}", uuid::Uuid::new_v4()); + if let Err(db_err) = self + .db + .record_standalone_task_item( + &crate::models::WorkerTrigger::Hydration, + &crate::models::SyncTaskItem { + id: 0, + task_id, + relative_path: remote_uri.clone(), + action_type: crate::models::TaskActionType::Hydration, + status: crate::models::TaskItemStatus::Failed, + file_size: remote_size, + error_message: Some(e.to_string()), + created_at: now_str.clone(), + updated_at: now_str, + }, + ) + .await + { + tracing::warn!("WCF 水合失败统计记录失败: {}", db_err); + } let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data( - request.connection_key, request.transfer_key, + request.connection_key, + request.transfer_key, ); return; } @@ -120,25 +159,72 @@ impl SyncEngine { offset as i64, ) { Ok(_) => { - tracing::debug!("WCF 水合数据推送: {} offset={} len={}", remote_uri, offset, transfer_data.len()); + tracing::debug!( + "WCF 水合数据推送: {} offset={} len={}", + remote_uri, + offset, + transfer_data.len() + ); - if let Ok(Some(mapping)) = self.db.find_mapping_by_remote_uri(&root_id, &remote_uri).await { - self.suppress_paths.insert(mapping.local_path.to_string_lossy().into_owned(), std::time::Instant::now()); - let _ = self.db.upsert_file_mapping(&FileMapping { - id: mapping.id, - sync_root_id: mapping.sync_root_id, - local_path: mapping.local_path.clone(), - remote_uri: mapping.remote_uri.clone(), - remote_file_id: mapping.remote_file_id.clone(), - local_hash: None, - remote_hash: if remote_hash.is_empty() { mapping.remote_hash.clone() } else { Some(remote_hash.clone()) }, - local_mtime: mapping.local_mtime, - remote_mtime: mapping.remote_mtime, - local_size: mapping.local_size, - remote_size: Some(remote_size), - sync_status: SyncFileStatus::Synced, - is_placeholder: false, - }).await; + // 仅在首次下载(cache miss)时更新映射和记录统计,避免同一文件多次 range 请求重复计数 + if is_new_download { + if let Ok(Some(mapping)) = self + .db + .find_mapping_by_remote_uri(&root_id, &remote_uri) + .await + { + self.suppress_paths.insert( + mapping.local_path.to_string_lossy().into_owned(), + std::time::Instant::now(), + ); + let _ = self + .db + .upsert_file_mapping(&FileMapping { + id: mapping.id, + sync_root_id: mapping.sync_root_id, + local_path: mapping.local_path.clone(), + remote_uri: mapping.remote_uri.clone(), + remote_file_id: mapping.remote_file_id.clone(), + local_hash: None, + remote_hash: if remote_hash.is_empty() { + mapping.remote_hash.clone() + } else { + Some(remote_hash.clone()) + }, + local_mtime: mapping.local_mtime, + remote_mtime: mapping.remote_mtime, + local_size: mapping.local_size, + remote_size: Some(remote_size), + sync_status: SyncFileStatus::Synced, + is_placeholder: false, + }) + .await; + + // 记录水合操作到统计 + let now_str = chrono::Utc::now().to_rfc3339(); + let local_path_str = mapping.local_path.to_string_lossy().to_string(); + let task_id = format!("hydration_{}", uuid::Uuid::new_v4()); + if let Err(e) = self + .db + .record_standalone_task_item( + &crate::models::WorkerTrigger::Hydration, + &crate::models::SyncTaskItem { + id: 0, + task_id, + relative_path: local_path_str, + action_type: crate::models::TaskActionType::Hydration, + status: crate::models::TaskItemStatus::Completed, + file_size: remote_size, + error_message: None, + created_at: now_str.clone(), + updated_at: now_str, + }, + ) + .await + { + tracing::warn!("WCF 水合统计记录失败: {}", e); + } + } } } Err(e) => { @@ -169,7 +255,11 @@ impl SyncEngine { if let Some(adapter) = self.platform_adapter.lock().unwrap().as_ref() { match adapter.create_placeholder_for_remote( local_path.parent().unwrap_or(local_root), - local_path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default().as_str(), + local_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default() + .as_str(), remote.size, &remote.uri, remote.hash.as_deref(), @@ -181,21 +271,24 @@ impl SyncEngine { } } - let _ = self.db.upsert_file_mapping(&FileMapping { - id: 0, - sync_root_id: root_id.to_string(), - local_path: std::path::PathBuf::from(relative), - remote_uri: remote.uri.clone(), - remote_file_id: remote.file_id.clone(), - local_hash: None, - remote_hash: remote.hash.clone(), - local_mtime: None, - remote_mtime: Some(remote.mtime_ms), - local_size: None, - remote_size: Some(remote.size), - sync_status: SyncFileStatus::Placeholder, - is_placeholder: true, - }).await; + let _ = self + .db + .upsert_file_mapping(&FileMapping { + id: 0, + sync_root_id: root_id.to_string(), + local_path: std::path::PathBuf::from(relative), + remote_uri: remote.uri.clone(), + remote_file_id: remote.file_id.clone(), + local_hash: None, + remote_hash: remote.hash.clone(), + local_mtime: None, + remote_mtime: Some(remote.mtime_ms), + local_size: None, + remote_size: Some(remote.size), + sync_status: SyncFileStatus::Placeholder, + is_placeholder: true, + }) + .await; } } @@ -241,31 +334,36 @@ impl SyncEngine { fn list_placeholders_sync(&self, sync_root_id: &str) -> anyhow::Result> { let pool = self.db.read_pool(); let conn = pool.get().map_err(|e| anyhow::anyhow!("{}", e))?; - let mut stmt = conn.prepare( - "SELECT id, sync_root_id, local_path, remote_uri, remote_file_id, + let mut stmt = conn + .prepare( + "SELECT id, sync_root_id, local_path, remote_uri, remote_file_id, local_hash, remote_hash, local_mtime, remote_mtime, local_size, remote_size, sync_status, is_placeholder FROM file_mapping WHERE sync_root_id = ?1 AND is_placeholder = 1", - ).map_err(|e| anyhow::anyhow!("{}", e))?; + ) + .map_err(|e| anyhow::anyhow!("{}", e))?; - let mappings = stmt.query_map(rusqlite::params![sync_root_id], |row| { - Ok(FileMapping { - id: row.get(0)?, - sync_root_id: row.get(1)?, - local_path: std::path::PathBuf::from(row.get::<_, String>(2)?), - remote_uri: row.get(3)?, - remote_file_id: row.get(4)?, - local_hash: row.get(5)?, - remote_hash: row.get(6)?, - local_mtime: row.get(7)?, - remote_mtime: row.get(8)?, - local_size: row.get(9)?, - remote_size: row.get(10)?, - sync_status: crate::sync_db::parse_sync_status(&row.get::<_, String>(11)?), - is_placeholder: row.get::<_, i32>(12)? != 0, + let mappings = stmt + .query_map(rusqlite::params![sync_root_id], |row| { + Ok(FileMapping { + id: row.get(0)?, + sync_root_id: row.get(1)?, + local_path: std::path::PathBuf::from(row.get::<_, String>(2)?), + remote_uri: row.get(3)?, + remote_file_id: row.get(4)?, + local_hash: row.get(5)?, + remote_hash: row.get(6)?, + local_mtime: row.get(7)?, + remote_mtime: row.get(8)?, + local_size: row.get(9)?, + remote_size: row.get(10)?, + sync_status: crate::sync_db::parse_sync_status(&row.get::<_, String>(11)?), + is_placeholder: row.get::<_, i32>(12)? != 0, + }) }) - }).map_err(|e| anyhow::anyhow!("{}", e))? - .filter_map(|m| m.ok()).collect(); + .map_err(|e| anyhow::anyhow!("{}", e))? + .filter_map(|m| m.ok()) + .collect(); Ok(mappings) } diff --git a/native/sync-core/src/worker/worker_impl.rs b/native/sync-core/src/worker/worker_impl.rs index a2f78a6..e0f8b55 100644 --- a/native/sync-core/src/worker/worker_impl.rs +++ b/native/sync-core/src/worker/worker_impl.rs @@ -79,6 +79,24 @@ impl Worker { } } + /// 发射任务项状态变更事件(供 UI 实时更新进度) + async fn emit_item_updated( + &self, + task_id: &str, + relative_path: &str, + action: &str, + status: &str, + ) { + self.event_sink + .emit(crate::api::ffi_types::SyncEventFfi::TaskItemUpdated { + task_id: task_id.to_string(), + relative_path: relative_path.to_string(), + action: action.to_string(), + status: status.to_string(), + }) + .await; + } + /// 执行 Worker 任务(编排器,调用各步骤方法) pub async fn run(mut self) -> Result { let start = Instant::now(); @@ -113,9 +131,12 @@ impl Worker { self.step_rename_remote(&mut summary).await; self.step_move_remote(&mut summary).await; self.step_scan_new_dirs(&mut summary).await; - self.step_resolve_conflicts(&mut summary, &transfer_semaphore).await; - self.step_execute_uploads(&mut summary, &transfer_semaphore).await; - self.step_execute_downloads_or_placeholders(&mut summary, &transfer_semaphore).await; + self.step_resolve_conflicts(&mut summary, &transfer_semaphore) + .await; + self.step_execute_uploads(&mut summary, &transfer_semaphore) + .await; + self.step_execute_downloads_or_placeholders(&mut summary, &transfer_semaphore) + .await; self.step_delete_local(&mut summary).await; self.step_rename_local(&mut summary).await; self.step_move_local(&mut summary).await; @@ -172,7 +193,10 @@ impl Worker { /// 1. 创建远程目录结构(UploadOnly / Full / MirrorWcf) async fn step_create_remote_dirs(&self) { - if matches!(self.config.sync_mode, SyncMode::DownloadOnly) { + if matches!( + self.config.sync_mode, + SyncMode::DownloadOnly | SyncMode::AlbumDownload + ) { return; } let tid = &self.task_id; @@ -197,7 +221,10 @@ impl Worker { /// 2. 创建本地目录结构(DownloadOnly / Full / MirrorWcf) async fn step_create_local_dirs(&self) { - if matches!(self.config.sync_mode, SyncMode::UploadOnly) { + if matches!( + self.config.sync_mode, + SyncMode::UploadOnly | SyncMode::AlbumUpload + ) { return; } let tid = &self.task_id; @@ -212,9 +239,12 @@ impl Worker { } } - /// 2.1 执行远程重命名(UploadOnly / Full / MirrorWcf) + /// 2.1 执行远程重命名(UploadOnly / Full / MirrorWcf / AlbumUpload) async fn step_rename_remote(&self, summary: &mut SyncSummary) { - if matches!(self.config.sync_mode, SyncMode::DownloadOnly) { + if matches!( + self.config.sync_mode, + SyncMode::DownloadOnly | SyncMode::AlbumDownload + ) { return; } let tid = &self.task_id; @@ -242,6 +272,8 @@ impl Worker { ); summary.renamed += 1; let _ = self.db.increment_task_completed(tid).await; + self.emit_item_updated(tid, &rename.new_relative_path, "rename", "completed") + .await; let new_remote_uri = { let uri = &rename.remote_uri; let last_slash = uri.trim_end_matches('/').rfind('/').unwrap_or(0); @@ -293,7 +325,10 @@ impl Worker { /// 2.2 执行远程移动(本地触发) async fn step_move_remote(&self, summary: &mut SyncSummary) { - if matches!(self.config.sync_mode, SyncMode::DownloadOnly) { + if matches!( + self.config.sync_mode, + SyncMode::DownloadOnly | SyncMode::AlbumDownload + ) { return; } let tid = &self.task_id; @@ -318,6 +353,8 @@ impl Worker { ); summary.moved += 1; let _ = self.db.increment_task_completed(tid).await; + self.emit_item_updated(tid, &mov.new_relative_path, "move", "completed") + .await; let _ = self .db .update_file_mapping_path( @@ -421,8 +458,7 @@ impl Worker { self.plan.mkdirs_remote.push(full_relative.clone()); } else { let mut local_entry = entry.clone(); - local_entry.relative_path = - std::path::PathBuf::from(&full_relative); + local_entry.relative_path = std::path::PathBuf::from(&full_relative); self.plan.uploads.push(SyncAction { relative_path: full_relative, local_entry: Some(local_entry), @@ -491,7 +527,11 @@ impl Worker { } /// 3. 处理冲突(串行) - async fn step_resolve_conflicts(&self, summary: &mut SyncSummary, transfer_semaphore: &Arc) { + async fn step_resolve_conflicts( + &self, + summary: &mut SyncSummary, + transfer_semaphore: &Arc, + ) { let tid = &self.task_id; let root_id = &self.config.sync_root_id; @@ -702,6 +742,13 @@ impl Worker { }; if conflict_ok { let _ = self.db.increment_task_completed(tid).await; + self.emit_item_updated( + tid, + &conflict.relative_path, + "conflict_resolve", + "completed", + ) + .await; } let _ = self .db @@ -716,9 +763,16 @@ impl Worker { } } - /// 4. 并发上传(UploadOnly / Full / MirrorWcf) - async fn step_execute_uploads(&self, summary: &mut SyncSummary, transfer_semaphore: &Arc) { - if matches!(self.config.sync_mode, SyncMode::DownloadOnly) { + /// 4. 并发上传(UploadOnly / Full / MirrorWcf / AlbumUpload) + async fn step_execute_uploads( + &self, + summary: &mut SyncSummary, + transfer_semaphore: &Arc, + ) { + if matches!( + self.config.sync_mode, + SyncMode::DownloadOnly | SyncMode::AlbumDownload + ) { return; } let tid = &self.task_id; @@ -762,6 +816,8 @@ impl Worker { Ok(Ok(_)) => { summary.uploaded += 1; let _ = self.db.increment_task_completed(tid).await; + self.emit_item_updated(tid, &rel_path, "upload", "completed") + .await; let _ = self .db .update_task_item_status_by_path( @@ -777,6 +833,8 @@ impl Worker { tracing::error!("[{}] 上传失败: {}: {}", tid, rel_path, e); summary.failed += 1; let _ = self.db.increment_task_failed(tid).await; + self.emit_item_updated(tid, &rel_path, "upload", "failed") + .await; let _ = self .db .update_task_item_status_by_path( @@ -794,6 +852,8 @@ impl Worker { tracing::error!("[{}] 上传任务异常: {}: {}", tid, rel_path, e); summary.failed += 1; let _ = self.db.increment_task_failed(tid).await; + self.emit_item_updated(tid, &rel_path, "upload", "failed") + .await; let _ = self .db .update_task_item_status_by_path( @@ -812,7 +872,11 @@ impl Worker { } /// 5. 并发下载(DownloadOnly / Full)或 创建占位符(MirrorWcf) - async fn step_execute_downloads_or_placeholders(&self, summary: &mut SyncSummary, transfer_semaphore: &Arc) { + async fn step_execute_downloads_or_placeholders( + &self, + summary: &mut SyncSummary, + transfer_semaphore: &Arc, + ) { let tid = &self.task_id; let root_id = self.config.sync_root_id.clone(); @@ -824,13 +888,17 @@ impl Worker { } let relative = &action.relative_path; let local_path = self.config.local_root.join(relative); + let _ = &local_path; // FUSE 模式下可能未使用 + // FUSE 模式下不需要创建本地目录/文件,FUSE inode 已在 initial_sync 中注册 + #[cfg(not(feature = "linux-fuse"))] if let Some(parent) = local_path.parent() { let _ = tokio::fs::create_dir_all(parent).await; } if let Some(ref remote) = action.remote_entry { if remote.is_dir { + #[cfg(not(feature = "linux-fuse"))] let _ = tokio::fs::create_dir_all(&local_path).await; } else { #[cfg(feature = "windows-cfapi")] @@ -878,7 +946,11 @@ impl Worker { let _ = tokio::fs::write(&local_path, []).await; } } - #[cfg(not(feature = "windows-cfapi"))] + #[cfg(all(feature = "linux-fuse", not(feature = "windows-cfapi")))] + { + // FUSE 模式: inode 已在 initial_sync 中注册,无需创建本地文件 + } + #[cfg(not(any(feature = "windows-cfapi", feature = "linux-fuse")))] { let _ = tokio::fs::write(&local_path, []).await; tracing::warn!( @@ -910,6 +982,8 @@ impl Worker { } let _ = self.db.increment_task_completed(tid).await; + self.emit_item_updated(tid, relative, "create_placeholder", "completed") + .await; let _ = self .db .update_task_item_status_by_path( @@ -921,7 +995,10 @@ impl Worker { ) .await; } - } else if !matches!(self.config.sync_mode, SyncMode::UploadOnly) { + } else if !matches!( + self.config.sync_mode, + SyncMode::UploadOnly | SyncMode::AlbumUpload + ) { let mut download_handles: Vec<(String, tokio::task::JoinHandle>)> = Vec::new(); for action in &self.plan.downloads { @@ -959,6 +1036,8 @@ impl Worker { Ok(Ok(_)) => { summary.downloaded += 1; let _ = self.db.increment_task_completed(tid).await; + self.emit_item_updated(tid, &rel_path, "download", "completed") + .await; let _ = self .db .update_task_item_status_by_path( @@ -974,6 +1053,8 @@ impl Worker { tracing::error!("[{}] 下载失败: {}: {}", tid, rel_path, e); summary.failed += 1; let _ = self.db.increment_task_failed(tid).await; + self.emit_item_updated(tid, &rel_path, "download", "failed") + .await; let _ = self .db .update_task_item_status_by_path( @@ -989,6 +1070,8 @@ impl Worker { tracing::error!("[{}] 下载任务异常: {}: {}", tid, rel_path, e); summary.failed += 1; let _ = self.db.increment_task_failed(tid).await; + self.emit_item_updated(tid, &rel_path, "download", "failed") + .await; let _ = self .db .update_task_item_status_by_path( @@ -1006,8 +1089,12 @@ impl Worker { } /// 6. 删除本地文件(DownloadOnly / Full / MirrorWcf — 远程删除触发的本地删除) + /// AlbumDownload 跳过:远程删除不应删除本地相册照片 async fn step_delete_local(&self, summary: &mut SyncSummary) { - if matches!(self.config.sync_mode, SyncMode::UploadOnly) { + if matches!( + self.config.sync_mode, + SyncMode::UploadOnly | SyncMode::AlbumUpload | SyncMode::AlbumDownload + ) { return; } let tid = &self.task_id; @@ -1023,6 +1110,13 @@ impl Worker { Ok(_) => { summary.deleted_local += 1; let _ = self.db.increment_task_completed(tid).await; + self.emit_item_updated( + tid, + &action.relative_path, + "delete_local", + "completed", + ) + .await; tracing::info!("[{}] 删除本地: {}", tid, action.relative_path); let _ = self .db @@ -1064,7 +1158,10 @@ impl Worker { /// 6.5 本地重命名(远程触发 → 本地执行 rename) async fn step_rename_local(&self, summary: &mut SyncSummary) { - if matches!(self.config.sync_mode, SyncMode::UploadOnly) { + if matches!( + self.config.sync_mode, + SyncMode::UploadOnly | SyncMode::AlbumUpload + ) { return; } let tid = &self.task_id; @@ -1089,6 +1186,8 @@ impl Worker { Ok(_) => { summary.renamed += 1; let _ = self.db.increment_task_completed(tid).await; + self.emit_item_updated(tid, &action.new_relative_path, "rename", "completed") + .await; tracing::info!( "[{}] 本地重命名: {} -> {}", tid, @@ -1141,7 +1240,10 @@ impl Worker { /// 6.6 本地移动(远程触发 → 本地执行 move) async fn step_move_local(&self, summary: &mut SyncSummary) { - if matches!(self.config.sync_mode, SyncMode::UploadOnly) { + if matches!( + self.config.sync_mode, + SyncMode::UploadOnly | SyncMode::AlbumUpload + ) { return; } let tid = &self.task_id; @@ -1166,6 +1268,8 @@ impl Worker { Ok(_) => { summary.moved += 1; let _ = self.db.increment_task_completed(tid).await; + self.emit_item_updated(tid, &action.new_relative_path, "move", "completed") + .await; tracing::info!( "[{}] 本地移动: {} -> {}", tid, @@ -1224,7 +1328,8 @@ impl Worker { let tid = &self.task_id; // 先标记抑制,30s 内 SSE 删除事件不会删本地文件 - self.suppress_paths.insert(relative_path.to_string(), std::time::Instant::now()); + self.suppress_paths + .insert(relative_path.to_string(), std::time::Instant::now()); match self.api.delete_files(&[&remote_uri]).await { Ok(_) => tracing::info!("[{}] 上传失败清理-已删除远端碎片: {}", tid, remote_uri), @@ -1252,8 +1357,15 @@ impl Worker { match self.api.delete_files(&remote_uris).await { Ok(_) => { summary.deleted_remote += remote_uris.len() as u32; - for _ in &remote_uris { + for action in &self.plan.delete_remote { let _ = self.db.increment_task_completed(tid).await; + self.emit_item_updated( + tid, + &action.relative_path, + "delete_remote", + "completed", + ) + .await; } for uri in &remote_uris { tracing::info!("[{}] 删除远程: {}", tid, uri); diff --git a/pubspec.lock b/pubspec.lock index 878bf1e..5bad395 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -289,6 +289,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + external_path: + dependency: "direct main" + description: + name: external_path + sha256: "68a18a2aa51ec012d7013ea2a80305dc5372f3577a2bbcc7dcc5550b25a5a73b" + url: "https://pub.dev" + source: hosted + version: "2.2.0" fake_async: dependency: transitive description: @@ -361,6 +377,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888 + url: "https://pub.dev" + source: hosted + version: "1.2.0" flutter: dependency: "direct main" description: flutter @@ -1177,10 +1201,10 @@ packages: dependency: "direct main" description: name: pdfrx - sha256: "0caa00aca2032cee5755873e88af849448534b95680895bfc57746dc3b4aea0d" + sha256: "6b3571565fb412fb7d0a325a76154d0685bd0a0659c3f41ee007f408d48cfd46" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.4.3" pdfrx_engine: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c05611a..5718342 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,7 +56,7 @@ dependencies: # 预览功能 flutter_cache_manager: ^3.4.1 cached_network_image: ^3.3.1 - pdfrx: ^2.2.24 + pdfrx: ^2.4.1 media_kit: ^1.2.6 media_kit_video: ^2.0.1 # For video rendering. media_kit_libs_video: ^1.0.7 # Native video dependencies. @@ -69,6 +69,7 @@ dependencies: # 下载 background_downloader: 9.5.4 path_provider: ^2.1.1 + external_path: ^2.2.0 permission_handler: ^11.1.0 url_launcher: ^6.2.1 open_file: ^3.5.11 @@ -109,6 +110,7 @@ dependencies: freezed_annotation: ^3.1.0 flutter_rust_bridge: ^2.12.0 uuid: ^4.5.3 + fl_chart: ^1.2.0 dev_dependencies: flutter_test: